REST API · v1.2

AgenciesWP REST API Documentation

The AgenciesWP REST API lets you read and write your agency data from your own systems, or from Zapier, n8n and Make through their HTTP modules. Simple keys, scoped permissions, and nothing is ever deleted.

HTTPS onlyScoped keysNo delete routes120 requests/min

Overview

The AgenciesWP REST API lets you read and write your clients, projects, invoices, time logs and more from your own systems, or from Zapier, n8n and Make through their HTTP modules. It runs inside your own WordPress site, so your data never passes through a third-party service.

It never deletesThere is no delete route for any object. Removing a record is done in the WordPress admin.
Settings stay privateSettings and payment credentials are not reachable through the API. Neither are API keys, portal sessions or sign-in links.
Scoped keysA key only reaches what you granted it, one permission at a time.

Messages and the activity log are not available through the API.

Base URL

Every request goes to the REST namespace on your own site:

url
https://your-site.com/wp-json/agencieswp/v1

Creating a key

In WordPress admin, go to Agencies WP → Settings → API. Give the key a label you will recognise later, such as “Zapier” or “Our CRM”, tick the permissions it needs, and save.

The key is shown once. It is stored hashed, so it cannot be shown again. If it is lost, revoke it and make another.

A key looks like this:

key
awp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Keys can be revoked at any time. A revoked key stops working immediately.

Authenticating

Send the key in a header. Either of these works:

header
Authorization: Bearer awp_your_key_here
header
X-AgenciesWP-Key: awp_your_key_here

Never put the key in the query string. The API rejects it there, because URLs end up in server logs, browser history and referrer headers.

HTTPS is required. A key sent over plain HTTP can be read by anything between you and the site, so requests over plain HTTP are refused outright. There is no setting to turn that off on a live site.

Checking it works

bash
curl -s https://your-site.com/wp-json/agencieswp/v1/ 
  -H "Authorization: Bearer awp_your_key_here"
json
{
  "name": "AgenciesWP API",
  "version": "1.2",
  "namespace": "agencieswp/v1",
  "key": "Zapier",
  "scopes": ["read:clients", "read:projects", "write:projects"],
  "notes": "This API never deletes. There are no delete routes.",
  "endpoints": { "...": "..." }
}

The index tells you which key you are using and exactly what it can reach, which makes it the quickest way to debug a permission error.

Permissions

A key holds whichever of these you grant it. Read permissions expose data. Write permissions create and update records, and never delete them.

PermissionWhat it allows
read:clientsRead clients
read:projectsRead projects
read:invoicesRead invoices
read:subscriptionsRead recurring invoices
read:proposalsRead proposals
read:timeRead time logs
read:milestonesRead milestones
read:expensesRead expenses
read:statsRead summary figures
write:clientsCreate and update clients
write:projectsCreate and update projects
write:invoicesCreate and update invoices
write:timeCreate and update time logs
write:milestonesCreate and update milestones
write:expensesCreate and update expenses

Grant the narrowest set that does the job. A key for a reporting dashboard needs no write permission at all.

Endpoints

Reading

MethodPathPermission
GET/clientsread:clients
GET/clients/{id}read:clients
GET/projectsread:projects
GET/projects/{id}read:projects
GET/invoicesread:invoices
GET/invoices/{id}read:invoices
GET/subscriptionsread:subscriptions
GET/subscriptions/{id}read:subscriptions
GET/proposalsread:proposals
GET/proposals/{id}read:proposals
GET/time-logsread:time
GET/time-logs/{id}read:time
GET/milestonesread:milestones
GET/milestones/{id}read:milestones
GET/expensesread:expenses
GET/expenses/{id}read:expenses
GET/statsread:stats

Writing

MethodPathPermission
POST/clientswrite:clients
PATCH / PUT/clients/{id}write:clients
POST/projectswrite:projects
PATCH / PUT/projects/{id}write:projects
POST/invoiceswrite:invoices
PATCH / PUT/invoices/{id}write:invoices
POST/time-logswrite:time
PATCH / PUT/time-logs/{id}write:time
POST/milestoneswrite:milestones
PATCH / PUT/milestones/{id}write:milestones
POST/expenseswrite:expenses
PATCH / PUT/expenses/{id}write:expenses

Recurring invoices and proposals are read-only. Both have schedules and signatures behind them that should be set up in the admin.

Listing and pagination

Query parameters

ParameterTypeDefaultNotes
per_pageinteger25How many records per page
pageinteger1Which page
statusstring—Filter by status, where the object has one
searchstring—Free-text search, where the object supports it
client_idinteger—Only records for this client
project_idinteger—Only records for this project. Required for /milestones and /time-logs. Both are always read per project, and leaving it out returns 400 project_required

Pagination headers

Every list response carries these headers. Page through until page reaches X-WP-TotalPages.

headers
X-WP-Total: 143
X-WP-TotalPages: 6

Example

bash
curl -s "https://your-site.com/wp-json/agencieswp/v1/invoices?status=overdue&per_page=50" 
  -H "Authorization: Bearer awp_your_key_here"
json
[
  {
    "id": 42,
    "invoice_number": "INV-0042",
    "client_id": 7,
    "project_id": 11,
    "subscription_id": 0,
    "amount": 4000.00,
    "tax": 800.00,
    "discount": 0.00,
    "total": 4800.00,
    "paid_amount": 0.00,
    "status": "overdue",
    "payment_method": "",
    "due_date": "2026-09-30",
    "paid_date": null,
    "notes": "",
    "items": [
      { "description": "Website rebuild - phase 1", "qty": 1, "rate": 4000.00 }
    ],
    "created_at": "2026-09-01 10:14:02",
    "updated_at": "2026-10-01 00:00:11"
  }
]

A list is a plain JSON array. A single record is a plain JSON object. There is no wrapper envelope.

Fields

Each object returns an explicit list of fields. It is an allow-list, not a blocklist, so a column added to the database in a later release stays invisible to the API until it is deliberately published. Secrets are never on these lists.

clients

id unique_id name email phone company title website address notes status billing_address country tax_id billing_email created_at updated_at

projects

id title description client_id status progress budget budget_type hourly_rate start_date due_date estimated_hours project_code service_type lead_user_id lead_name created_at updated_at

invoices

id invoice_number client_id project_id subscription_id amount tax discount total paid_amount status payment_method due_date paid_date notes items created_at updated_at

subscriptions (recurring invoices)

id title client_id project_id items amount tax discount total frequency start_date next_run_date end_date max_cycles cycles_completed due_days auto_send status last_invoice_id created_at updated_at

proposals

id proposal_number title recipient_name recipient_email recipient_company amount tax discount total status valid_until client_id project_id sent_at viewed_at responded_at created_at updated_at

time-logs

id project_id user_id log_date hours hourly_rate billable description created_at

milestones

id project_id title description status due_date assigned_to sort_order created_at

expenses

id description vendor category project_id client_id amount tax total expense_date status billable invoice_id receipt_url notes created_at updated_at

The items field on invoices, recurring invoices and proposals is stored as JSON and returned as real JSON, not a string.

Creating records

Send JSON with Content-Type: application/json. Form-encoded bodies work too.

A client

bash
curl -s -X POST https://your-site.com/wp-json/agencieswp/v1/clients 
  -H "Authorization: Bearer awp_your_key_here" 
  -H "Content-Type: application/json" 
  -d '{
    "name": "Harbor Fintech",
    "email": "[email protected]",
    "company": "Harbor Fintech Ltd",
    "country": "GB",
    "tax_id": "GB123456789"
  }'

A successful create returns 201 with the new record, including the id the system assigned:

json
{ "id": 18, "unique_id": "CL100018", "name": "Harbor Fintech", "...": "..." }

unique_id is generated for you. It is the reference the client uses to sign in to their portal, so it cannot be supplied.

A project

bash
curl -s -X POST https://your-site.com/wp-json/agencieswp/v1/projects 
  -H "Authorization: Bearer awp_your_key_here" 
  -H "Content-Type: application/json" 
  -d '{
    "title": "Website rebuild",
    "client_id": 18,
    "status": "in_progress",
    "budget_type": "fixed",
    "budget": 12000,
    "due_date": "2027-02-28",
    "service_type": "Web design",
    "estimated_hours": 140
  }'

For an hourly project, send "budget_type": "hourly" with an hourly_rate. The budget then follows the hours logged against it.

A time log

bash
curl -s -X POST https://your-site.com/wp-json/agencieswp/v1/time-logs 
  -H "Authorization: Bearer awp_your_key_here" 
  -H "Content-Type: application/json" 
  -d '{
    "project_id": 11,
    "log_date": "2026-10-28",
    "hours": 3.5,
    "billable": 1,
    "description": "Checkout flow QA"
  }'

Updating records

Use PATCH (or PUT) with only the fields you want changed. Anything you leave out keeps its current value.

bash
curl -s -X PATCH https://your-site.com/wp-json/agencieswp/v1/invoices/42 
  -H "Authorization: Bearer awp_your_key_here" 
  -H "Content-Type: application/json" 
  -d '{ "status": "paid", "paid_amount": 4800.00, "paid_date": "2026-10-29" }'

It returns 200 with the updated record.

Summary figures

One call returns the headline numbers, which is handy for a dashboard tile without paging through every record.

bash
curl -s https://your-site.com/wp-json/agencieswp/v1/stats 
  -H "Authorization: Bearer awp_your_key_here"
json
{
  "clients": { "total": 18 },
  "projects": {
    "total": 24, "pending": 2, "in_progress": 9, "completed": 13, "on_hold": 0
  },
  "invoices": {
    "total_count": 96,
    "total_invoiced": 184300.00,
    "total_paid": 171900.00,
    "total_due": 12400.00,
    "overdue_count": 2
  },
  "recurring": { "total": 7, "active": 6, "paused": 1, "mrr": 4180.00 },
  "currency": "USD"
}

mrr is monthly recurring revenue, with each plan’s amount normalised to a month. A quarterly plan counts as a third of its value, and a yearly one as a twelfth.

Errors

Errors come back in WordPress’s standard shape, with a matching HTTP status:

json
{
  "code": "insufficient_scope",
  "message": "This API key does not have the "write:invoices" permission.",
  "data": { "status": 403 }
}
StatusCodeMeaning
401missing_keyNo key in the request headers
401invalid_keyThe key is not valid. Check it was copied in full and has not been revoked
403https_requiredThe request came over plain HTTP
403insufficient_scopeValid key, but without the permission this route needs
404not_foundNo record with that id
404unknown_routeNo such resource
400project_required/milestones or /time-logs was listed without a project_id
400duplicate_emailA client with that email address already exists
400missing_nameA required field was not supplied
400otherThe record could not be created or updated. The message says what was wrong
429rate_limitedOver the per-minute allowance

Rate limit

The limit is 120 requests per minute per key. It is counted per key rather than per IP, so one integration cannot exhaust another’s allowance.

Going over returns 429. Wait for the next minute and continue. If you are importing in bulk, space the requests out, or page with a larger per_page instead of many small requests.

Integration tips

  • Use one key per integration. If one has to be revoked, nothing else stops working.
  • Grant the narrowest permissions that do the job. A key that only reads invoices cannot touch a client record, even if it is leaked.
  • Store the key as a secret, not in a repository or a shared document.
  • Use GET / as your health check. It confirms the key works and lists exactly what it can reach, which is faster than guessing at a 403.
  • Nothing here deletes. If a record needs removing, do it in the WordPress admin.
  • project_id is required on /milestones and /time-logs. Both are always read per project. There is no “all milestones” list.
  • Client email addresses are unique. Creating a client with an address that already exists returns 400 duplicate_email rather than a second record, so an import that runs twice does not produce duplicates.

Support

Questions, or something not behaving as documented? Get in touch and I will help.

Ready to automate your agency?

The REST API is built into AgenciesWP. Create a key in Settings and start connecting.

See pricingBack to documentation