Requests, errors and limits
These rules apply to every endpoint under https://app.getdrum.com/api/v1/ unless a resource page says otherwise.
Request bodies
- Send JSON with
Content-Type: application/json. - Write endpoints accept their fields at the top level, or nested under the resource name (for example
cost,tracked_time,opportunity). The resource page names the key. - Keys that an endpoint does not know are ignored, unless the page says they are refused.
- On a
PATCH, a field you do not send keeps its value.nullclears an optional field.
Pagination
List endpoints return a plain JSON array, one page at a time.
| Parameter | Default | Notes |
|---|---|---|
page |
1 |
Page number |
items |
20 |
Rows per page, at most 100. limit is accepted as another name for it |
No total count and no pagination headers are returned. Request the next page until a page comes back with fewer rows than items.
Errors
Use the HTTP status as the result, and code (when present) for program logic.
Most errors raised while handling a request have this body:
{"error": "duration_minutes must be a whole number of minutes from 0 to 1440", "code": "invalid_input", "field": "duration_minutes"}
| Key | Present |
|---|---|
error |
Always. A message for people. Do not match on it |
code |
On most errors. A stable machine-readable reason |
field |
On input and lookup errors. The input concerned, such as line_items[0].price_cents |
errors |
On validation_failed. A list of messages |
Some responses do not follow that shape:
| Response | Body |
|---|---|
401 |
Empty |
403 from the API permission check |
{"error": "Insufficient API permissions"} |
403 from an older record check |
{"error": "Forbidden"} |
404 on some reads (projects, opportunities, invoices, write-offs, planned invoices) |
Empty |
429 from the rate limiter |
{"error": "Rate limit exceeded. Retry later."} |
| Status | Meaning |
|---|---|
200 |
Success, or a replay of an earlier create (see idempotency keys) |
201 |
Created |
401 |
Missing, unknown or expired token |
403 |
The token’s user lacks a permission |
404 |
Not found, or hidden from the token’s user |
409 |
A conflict: the record changed, another request holds it, or an idempotency key was reused. Read the record again before you retry. finance_sync_paused means a Google Sheets import is running for the account, so nothing was sent to the accounting system. Retry after the import |
422 |
The input is invalid or a business rule refused it. Retrying the same request will not help |
429 |
Too many requests. Wait for Retry-After seconds |
Values
| Kind | Format |
|---|---|
| Ids | Integers, except the account id, which is a string such as acct_... |
| Dates | yyyy-mm-dd |
| Timestamps | ISO 8601 with a time zone, for example 2026-09-10T02:14:07.412Z |
| Durations | Whole minutes. Fields ending _hours give the same value in hours |
| Money | Always tax exclusive. Fields ending _cents are integers in cents. Other money fields (on projects, opportunities and invoices) are decimals in the record’s currency |
| Currency | ISO 4217 code in the record’s currency field |
When you send money, send integer cents. A float, a dollar amount or a negative value is refused with invalid_input.
Idempotency keys
These creates accept an Idempotency-Key header, so a retry after a timeout cannot create a second record:
POST /projects/:project_id/tracked_timesandPOST /members/:member_id/tracked_timesPOST /projects/:project_id/costsPOST /opportunitiesPOST /uploadsPOST /beatsPOST /proposals/:proposal_id/sends(the key is required here)
Contacts and notes take the key as an idempotency_key field in the body instead. See Companies and contacts and Notes.
| Rule | Detail |
|---|---|
| Key | 1 to 255 characters, no control characters. Use a new value, such as a UUID, for each new record |
| First request | Creates the record and returns 201 |
| Same key, same body | Creates nothing. Returns 200, the first record, and the header Idempotent-Replayed: true |
| Same key, different body | 409 idempotency_key_reused |
| Failed request | Stores no key, so a retry with the same key runs again |
| Lifetime | 24 hours for each token user. After that the key creates a new record |
Other replay errors: 409 retry_later (another request with the same expired key is running), 409 idempotent_record_deleted (the record the key made was deleted), and 404 idempotent_record_not_found (the token’s user can no longer read it).
Converting an opportunity has its own replay rule. See Opportunities.
Rate limits
| Limit | Applies to |
|---|---|
| 300 requests a minute, per account | Every /api/v1/ request made with the account’s tokens, except the webhook subscription endpoints |
| 30 a minute, per account | POST /opportunities/:id/convert |
| 100 requests a minute, per IP address | The webhook subscription endpoints (/api/v1/webhook_subscriptions) |
| 1,000 requests every 5 minutes, per IP address | Every request to Drum from one address |
A request over a limit returns 429 with these headers:
| Header | Meaning |
|---|---|
Retry-After |
Seconds until the window resets |
RateLimit-Limit |
The limit that was reached |
RateLimit-Remaining |
0 |
RateLimit-Reset |
Unix time when the window resets |
Wait for Retry-After, then retry with exponential backoff.
Xero limits
Xero limits how many calls Drum can make to one Xero organisation (60 a minute). Drum counts its own calls and keeps some room for calls made through the API, but a request that has to call Xero can still be refused before anything is sent. It then returns 429 with this body:
{"error": "Too many Xero requests. Retry later.", "code": "xero_rate_limited"}
The response has Retry-After, RateLimit-Remaining: 0 and RateLimit-Reset, but no RateLimit-Limit. Allocating a Xero invoice answers its own 429 without a code.