Reference · v1
Drupd REST API reference.
Every /v1 endpoint your integrations need, on one scrollable page: authentication, errors, idempotency, pagination, webhooks, and the limits and contracts they hit.
Base URL
api.drupd.com/v1
Auth
Bearer · scoped
Rate limit
600 / min / key
Idempotency
24h replay
Webhooks
HMAC-SHA256
Versioning
URL · /v1
Overview
A REST API for Pro users to create, send, and track invoices from any system that can make an HTTPS request.
The Drupd API lets Pro subscribers create, send, and track invoices from any system that can make an HTTPS request. The same surface will back the upcoming WordPress plugin and any integrator workflow that wants to create invoices from outside the dashboard.
Quick start
curl https://api.drupd.com/v1/me \
-H "Authorization: Bearer drupd_live_..."
Three things make Drupd's API distinct from a generic invoicing endpoint:
- Sends are first-class.
POST /v1/invoiceswithsend: truecreates the invoice, renders the PDF, and emails the client in a single call. The activation moment is one request, not three. - Idempotency is server-side. Pass an
Idempotency-Keyheader on every write and Drupd guarantees byte-identical replay for 24 hours. Safe to retry over flaky connections. - Webhooks loop back. Subscribe to
invoice.paid,invoice.viewed,invoice.overdueand similar events so your downstream system (WooCommerce order fulfillment, CRM record, accounting export) closes the loop without polling.
Base URL
https://api.drupd.com/v1
api.drupd.com is the canonical API host and serves only the API: any path outside /v1 on it returns a JSON 404. The same endpoints also answer on https://drupd.com/v1 as a permanent alias, so older integrations keep working; point new code at api.drupd.com.
Versioning
The version is in the URL (/v1/) and stable. Breaking changes will only ship as /v2/, and Drupd will keep /v1/ running for at least 12 months after that. Within /v1/, additive changes (new endpoints, new fields, new event types) ship without warning; they are not breaking.
Authentication
Every supported endpoint request requires a Bearer token. See Authentication for how to issue one, what scopes mean, and how to revoke a key.
Limits
The defaults are sized for a freelancer-tier product:
Auth → Bearer tokens, hash-stored, scoped
Rate limit → 600 req/min per API key
Pre-auth IP limit → 600 req/min per IP, checked before the per-key limit
Sends → 200 per day per workspace, 30 per minute burst
Monthly send cap → 500 per workspace on Pro (email sales to raise)
Idempotency → 24 hour replay window
Max request body → 1 MiB
Webhooks → 10 endpoints per workspace, HMAC-SHA256 signed
Traffic limits return 429 with a Retry-After header and a clear error code (rate_limit.exceeded, rate_limit.api_send_burst, rate_limit.api_send_daily_exceeded, or rate_limit.api_monthly_send_exceeded). Resource caps, such as the 10 webhook endpoints per workspace cap, return 409 when the workspace already owns the maximum allowed resources. To raise a daily or monthly send limit for a one-time migration or campaign, email sales@drupd.com.
Conventions
- All field names use
snake_case. - All monetary amounts are fixed-point decimal strings (
"1234.50"). JSON numbers (1234.5) are also accepted on input and normalized server-side; strings are the canonical form because IEEE 754 can't represent every decimal exactly. Pair them withcurrency_minor_unitif you prefer integer math on your side. - All timestamps are ISO 8601 with timezone (
2026-05-18T12:00:00Z). Plain date fields (issue_date,due_date) useYYYY-MM-DDand round-trip cleanly between reads and writes. - All IDs are UUIDs. Invoices additionally carry a
public_id(inv_<12-char>) for short shareable URLs. - Large list endpoints use cursor pagination (
?cursor=...&limit=50). The cursor is opaque; treat it as a black box.GET /v1/webhook_endpointsreturns the full capped endpoint list for the workspace. - Every response carries
Drupd-Request-Id: req_…in the header andrequest_idin the body. Include the value in support requests: we can find your exact call in our logs.
Need a key?
Mint one from /dashboard/settings/api. Keys are shown to you exactly once. Drupd persists only the SHA-256 hash and the last four characters for display; if you lose the key, revoke and reissue.
Authentication
Hash-stored Bearer tokens with optional read-only scope. Issued from Settings, revoked instantly.
Every supported endpoint request authenticates with an API key in an Authorization: Bearer … header.
curl https://api.drupd.com/v1/me \
-H "Authorization: Bearer drupd_live_..."
Issuing a key
Mint a key from /dashboard/settings/api. Drupd shows you the plaintext exactly once at creation. After you close the reveal dialog, only the last four characters and your label remain visible. There is no way to recover a lost key; revoke and reissue.
Keys never expire. They survive until you revoke them or delete the workspace.
Each workspace can hold at most 25 active API keys, managed from the same Settings tab. Revoked keys do not count against the cap; revoke an unused key to make room for a new one.
Scopes
Each key has a scope chosen at creation. The scope cannot be changed afterward; if you need a different one, create a new key and revoke the old.
| Scope | Can read | Can write | Use for |
|---|---|---|---|
| full | yes | yes | Default. Backend integrations that issue invoices. |
| read | yes | no | Reporting, exports, dashboards. |
A read key calling a write endpoint returns 403 auth.scope_denied.
Revoking a key
Revoke from the same Settings tab. Revocation propagates on the next request; there is no caching window. The key flips to status: revoked and any request using it returns 401 auth.invalid from then on.
If you suspect a key has leaked, revoke immediately. You can then issue a replacement under a new label so you can tell which integration was rotated.
Key prefixes
The plaintext format is drupd_<env>_<random>. Today the only valid env is live. The prefixes drupd_test_ and drupd_restricted_ are reserved for future sandbox and finer-grained scopes; do not use these strings as key labels. A well-formed token with a reserved (non-live) prefix is rejected with 401 auth.invalid, the same response as an unknown key, so we don't leak which prefixes are live.
Plan gating
The API requires a paid Pro plan. A Pro trial unlocks every other Pro feature but not the API: trial workspaces get 402 plan.pro_required until they upgrade to a paid plan. Likewise, if a workspace downgrades to Free or its trial expires, every API request returns 402 plan.pro_required on the very next call; there is no caching window. Existing keys stay in the database, marked active; they resume working the moment the workspace is on a paid Pro plan.
What we store
Drupd persists three things per key:
SHA-256(plaintext)for the lookup. The plaintext is never written to disk.- The last four characters of the plaintext, for display.
- An audit trail of
last_used_atandlast_used_ip.
A database leak does not let an attacker replay your key. The hash is one-way; the last four are useless on their own.
Identifying which key is calling
Hit GET /v1/me to confirm which workspace and which key you are authenticated as before doing anything destructive:
curl https://api.drupd.com/v1/me \
-H "Authorization: Bearer drupd_live_..."
{
"data": {
"organization": {
"object": "organization",
"id": "df949e42-…",
"name": "Acme Studio",
"plan": "pro",
"trial_ends_at": null,
"default_currency": "EUR",
"timezone": "Europe/Madrid",
"locale": "en-GB",
"invoice_prefix": "INV"
},
"api_key": {
"object": "api_key",
"id": "8b7c…",
"name": "WooCommerce production",
"prefix": "live",
"last4": "7f3a",
"scope": "full",
"api_version": null,
"created_at": "2026-05-12T11:24:00Z"
}
},
"object": "me",
"request_id": "req_…"
}
The api_key.id is a stable identifier for the key, useful in audit logs and support tickets, since the plaintext is hashed and unreachable. api_version is reserved for future dated revisions; it is null today.
Errors
Typed error envelope with codes, parameter names, and request IDs.
Every error response wraps the same envelope:
{
"error": {
"type": "invalid_request_error",
"code": "invoice.client_required",
"message": "Either `client_id` or `client` must be provided.",
"param": "client_id",
"request_id": "req_01abcdefghijklmnopqrstu"
}
}
The HTTP status tells you the broad failure class, but always trust code over message. Some error types intentionally appear under more than one status; for example invalid_request_error covers validation failures, 405 method errors, 409 resource caps, and 413 body-size failures. The message is for humans; the code is the contract.
Types and status codes
| HTTP | type | What it means |
|---|---|---|
| 400 | invalid_request_error | Validation, malformed JSON, unknown enum value. |
| 401 | authentication_error | Missing, malformed, or revoked Bearer. |
| 402 | plan_limit_error | Workspace is not on a paid Pro plan (trials excluded), or a plan quota such as the monthly invoice limit is reached. |
| 403 | permission_error | Key scope does not allow this endpoint. |
| 404 | not_found_error | Resource missing or not visible to this key's workspace. |
| 405 | invalid_request_error | Endpoint exists but not for this HTTP method. Carries an Allow header listing the methods this URL accepts. |
| 409 | idempotency_error | Same Idempotency-Key, different body, or still in flight. |
| 409 | invalid_request_error | Resource cap or state conflict (e.g. 10 webhook endpoints/org, a supplied invoice_number already in use, or sending a non-draft invoice). |
| 413 | invalid_request_error | Request body exceeded the 1 MiB cap. |
| 422 | invalid_request_error | Data precondition failed. The body parsed fine, but the data can't be persisted or acted on (e.g. an empty issue_date, or a draft missing the details required to send). Fix the data, not the request shape. |
| 429 | rate_limit_error | Per-key request limit, per-org burst, per-org daily cap, or per-org monthly cap exceeded. |
| 500 | internal_error | Bug on our side. Include the request_id if you contact support. |
Common codes
| Code | When |
|---|---|
| auth.missing_bearer | No Authorization header. |
| auth.malformed_bearer | Header present but not Bearer drupd_live_…. |
| auth.invalid | Hash unknown, key revoked, or workspace deleted. |
| auth.scope_denied | read key called a write endpoint. |
| plan.pro_required | Workspace is on Free, on a trial, or its trial expired; the API needs a paid Pro plan. |
| rate_limit.exceeded | 600 req/min per key. |
| rate_limit.api_send_burst | 30 API sends/min per workspace. |
| rate_limit.api_send_daily_exceeded | 200 API sends/24h per workspace. Email sales@drupd.com to raise it. |
| rate_limit.api_monthly_send_exceeded | API monthly send cap reached (500 on Pro). Email sales@drupd.com to raise it. |
| idempotency.payload_mismatch | Same key, different body. |
| idempotency.in_flight | A request with this key is still running. Retry once the original completes. |
| idempotency.invalid_key | Idempotency-Key was blank or longer than 255 characters. |
| request.invalid | Generic validation. Inspect param. |
| request.cursor_invalid | Pagination cursor is malformed. |
| request.payload_too_large | Request body exceeded the 1 MiB cap. |
| request.method_not_allowed | Endpoint exists but not for this HTTP method. |
| request.unknown_endpoint | No /v1 route matches the requested path. |
| invoice.client_required | Neither client_id nor client was provided. |
| invoice.client_ambiguous | Both client_id and client were supplied; pass exactly one. |
| invoice.client_email_required | send: true requires the client to have an email or billing_email. |
| invoice.dates_required | A supplied issue_date or due_date is empty or not a real calendar date (422). |
| invoice.monthly_limit_reached | Your plan's monthly invoice limit is reached (402). Upgrade to create more. |
| invoice.number_conflict | The supplied invoice_number already exists in the workspace, including on soft-deleted invoices (409). |
| invoice.not_draft | The invoice exists but its current state conflicts with the requested transition: it was already sent, paid, or cancelled, or it is locked (409). |
| invoice.send_precondition_failed | The invoice is a draft but is missing data required to send it: client details, client email, or line items (422). The param field names what's missing. |
| invoice.not_found | The invoice does not exist in this workspace. |
| invoice.not_outstanding | POST /v1/invoices/{id}/payments or /follow_up on an invoice that is not outstanding: it is a draft, already paid, cancelled, or lost (422). |
| invoice.payment_exceeds_balance | A recorded payment is larger than the invoice's remaining balance (422). |
| invoice.not_cancellable | POST /v1/invoices/{id}/cancel on a paid invoice (409). |
| invoice.not_lostable | POST /v1/invoices/{id}/mark_lost on an invoice that is not outstanding (sent/viewed/partial/overdue) (409). |
| invoice.not_deletable | DELETE /v1/invoices/{id} on a sent or paid invoice (409). Only draft or cancelled invoices can be deleted. |
| invoice.not_schedulable | POST /v1/invoices/{id}/schedule on a non-draft invoice (409). |
| invoice.schedule_in_past | scheduled_send_at is not a future datetime (422). |
| invoice.not_scheduled | POST /v1/invoices/{id}/cancel_schedule when no send is scheduled (409). |
| plan.schedule_send_unavailable | Scheduling on a non-Pro workspace (402). |
| plan.follow_ups_unavailable | Sending a follow-up on a non-Pro workspace (402). |
| plan.recurring_unavailable | Recurring invoices on a non-Pro workspace (402). |
| follow_up.send_failed | A follow-up was accepted but the email provider rejected it (502). |
| email.not_configured | Email sending isn't configured for this environment, e.g. follow-up in a preview deploy with no provider key (503). |
| recurring_invoice.not_found | The recurring schedule does not exist in this workspace. |
| recurring.template_not_eligible | A recurring template invoice is still a draft (must be sent or paid) (422). |
| recurring.invalid_date_range | A recurring schedule's end_date is not after start_date (422). |
| client.not_found | The client does not exist in this workspace. |
| client.limit_reached | Your plan's client limit is reached (402). Upgrade to add more. |
| client.in_use | DELETE /v1/clients/{id} on a client that still has invoices (409). The message includes the invoice count. |
| webhook_endpoint.not_found | The webhook endpoint does not exist in this workspace. |
| webhook_delivery.not_found | The webhook delivery does not exist for this endpoint. |
| webhook.invalid_url | Non-HTTPS URL or an obviously unsafe webhook hostname/address literal was supplied. |
| webhook.test_rate_limited | Too many test events: each workspace can queue at most 10 test deliveries per minute (429). |
| webhook.endpoint_limit_reached | Workspace has hit the 10 webhook endpoints/org cap. Delete one to add another. |
| server.internal_error | Unhandled server bug. Pass us the request_id. |
Not-found semantics
A lookup that targets a resource in another workspace returns the same per-resource code as a true miss (invoice.not_found, client.not_found, …). The two cases are deliberately indistinguishable: Drupd never reveals that a resource exists in a workspace your key cannot see.
Path id validation
A malformed id in the path is a request-shape problem, not a missing resource: it returns 400 request.invalid, never a 404. Invoice ids accept either the UUID or the inv_ public id; webhook endpoint and delivery ids must be UUIDs.
Never trust the message field
The message field is freely-translated copy intended for log lines and support transcripts. Build your application logic against type and code. We promise not to silently change either; we may freely change message.
Retry-After
On any 429 the response carries a Retry-After header in seconds. The value reflects which cap fired:
- Per-key per-minute and burst caps → ~60 seconds.
- Daily send cap → a conservative seconds-until-midnight-UTC hint for the rolling 24h window.
- Monthly send cap → seconds until the next UTC calendar month rolls over.
Sleep at least that long before retrying. Hammering past Retry-After only delays your reset.
Send-cap headers
Every 2xx from POST /v1/invoices (when send: true), POST /v1/invoices/{id}/send, and POST /v1/invoices/{id}/schedule (scheduling is checked against the caps up front; the send counts when it fires) carries:
| Header | Meaning |
|---|---|
| X-Drupd-Sends-Remaining-Day | Sends left in the rolling 24h window for this workspace. |
| X-Drupd-Sends-Remaining-Month | Sends left in the current UTC calendar month for this workspace. |
Use them to self-throttle before you hit a 429. You can also watch the same monthly count in the dashboard: Settings → API keys shows the workspace total against the cap, plus a per-key breakdown.
Request IDs
Every response (success or error) carries a Drupd-Request-Id header and the same value in request_id inside the body. Include it when you reach out to support: it is the fastest way for us to find the exact failure in our logs.
Idempotency
Replay the same Idempotency-Key for 24 hours and get the original response, byte-identical.
Network is flaky. Background workers crash. Browsers retry. Drupd's writes are designed to be safe under all three.
The contract
Pass an Idempotency-Key header on any POST, PATCH, or DELETE. The first request executes the mutation and stores the response keyed on (api_key, idempotency_key). For the next 24 hours, repeating the same request with the same key:
- Identical body → returns the original response byte-identical, including the original status code and
request_id, with the extra headerDrupd-Idempotency-Replay: true. The mutation does not run again. - Different body → returns
409 idempotency.payload_mismatch. No mutation runs. Either pick a fresh key or replay the original request exactly.
Only 2xx responses are stored for replay. If the original request fails (4xx or 5xx), the key is released: a retry with the same key re-executes the request fresh rather than replaying the failure. You never need a new key just because the first attempt errored.
Key format
A UUID is the easiest choice: they collide approximately never. The header accepts any non-blank string up to 255 characters on write requests. Blank or overlong keys return 400 idempotency.invalid_key. Examples:
Idempotency-Key: 7e3e0b62-bc09-4d35-9ee3-6c3a6f8d3f10
Idempotency-Key: woocommerce-order-184293
Idempotency-Key: 2026-05-18-payroll-acme
A good idempotency key is deterministic for the same logical operation. For a WordPress plugin: the WooCommerce order ID is a good key, since repeating it never accidentally bills the customer twice. For a cron-driven script: an ISO date + a campaign name is good.
What replay does not do
Replay returns the original response. If the underlying resource has changed since (the invoice was paid, the client was deleted), the cached body still reflects the moment of creation. To get the current state, hit GET /v1/invoices/{id} after a replay.
Replay does not regenerate PDFs or re-send emails. The original send happens exactly once.
Expiry
After 24 hours, the replay window expires. A replayed key past its window is treated as a fresh request, and the mutation will run again. If your retry path could span longer than 24 hours, do not rely on idempotency alone; check the resource state first with a GET.
Reads are already safe
GET requests do not need (and ignore) Idempotency-Key. Read endpoints have no side effects, so repeat them freely.
Resource-bound write endpoints are auto-dedup'd
A small set of endpoints are automatically idempotent by the resource id in the path, even when you don't pass Idempotency-Key:
| Endpoint | Auto key | Dedupe scope |
|---|---|---|
| POST /v1/invoices/{id}/send | auto:send:{id} | by invoice (send is once-only) |
| POST /v1/invoices/{id}/payments | auto:payment:{id}:{body-hash} | by invoice and request body |
A retry without an explicit key is safe: Drupd will not send the email twice, double-charge the send cap, or record the same payment twice. Pass an explicit Idempotency-Key when you want the standard 24-hour byte-identical replay response or want to control the dedupe scope yourself; the explicit value always wins.
Send is once-only per invoice, so its auto key is the invoice id alone; any retry dedupes. Payments accumulate, so the auto key also includes a hash of the request body: a byte-identical retry of the same payment dedupes, while two different payments (different amount) stay independent. One consequence: two intentionally-identical payments within the 24-hour window collapse to one unless you pass a distinct explicit Idempotency-Key, a deliberately safe default for money.
POST /v1/invoices/{id}/duplicate is not auto-keyed: duplicating is a legitimately repeatable action (you may want several copies), so an id-only key would wrongly collapse intended repeats and there is no body to disambiguate on. Pass an explicit Idempotency-Key if you need a duplicate retry to be safe.
The auto key is derived from the invoice id as it appears in the path. The same invoice addressed once by its UUID and once by its public_id (inv_…) yields two different auto keys, so a retry that switches id forms is not auto-deduped. If your retry path might use either form, pass an explicit Idempotency-Key so the dedupe is independent of which id you send.
Example
curl -X POST https://api.drupd.com/v1/invoices \
-H "Authorization: Bearer drupd_live_..." \
-H "Idempotency-Key: woocommerce-order-184293" \
-H "Content-Type: application/json" \
-d '{
"client": { "name": "Acme Studio", "email": "billing@acme.example" },
"line_items": [
{ "description": "Quarterly retainer", "quantity": 1, "unit_price": 4200 }
],
"send": true
}'
First call: 201 Created with the new invoice. Second call, same body: the same status code as the original (201 Created in this example), the same body byte-for-byte, plus Drupd-Idempotency-Replay: true. Third call, same key, different body: 409 idempotency.payload_mismatch.
Pagination
Opaque cursor pagination, never offset.
Large list endpoints use cursor pagination. Pass limit (max 100 everywhere) and an optional opaque cursor from the previous response's meta.next_cursor. The default limit is per-endpoint: 25 on GET /v1/invoices, 50 on GET /v1/webhook_endpoints/{id}/deliveries.
GET /v1/invoices and GET /v1/webhook_endpoints/{id}/deliveries are paginated. GET /v1/webhook_endpoints returns the full capped endpoint list for the workspace (max 10) with meta.has_more: false.
# First page
curl https://api.drupd.com/v1/invoices?limit=50 \
-H "Authorization: Bearer drupd_live_..."
# Next page
curl "https://api.drupd.com/v1/invoices?limit=50&cursor=eyJjcm…" \
-H "Authorization: Bearer drupd_live_..."
Response shape:
{
"data": [ /* items */ ],
"object": "list",
"meta": {
"has_more": true,
"next_cursor": "eyJjcm…"
},
"request_id": "req_…"
}
Stop paginating when meta.has_more is false. meta.next_cursor is null on the final page.
Treat the cursor as opaque
The cursor encodes (created_at, id) of the last row in the page, base64url-encoded. We may change the encoding without warning. Do not parse it. Do not synthesize one.
Stability
Pagination is stable across inserts: an item created after you started paging will not appear in later pages, and an item deleted will be skipped silently. This is the right behavior for "export everything in the workspace as of now" workflows.
For "list everything that has changed since timestamp X" workflows, combine ?cursor= with a per-resource filter (e.g. ?status=paid on invoices) rather than relying on the page boundary.
Errors
| Code | When |
|---|---|
| request.cursor_invalid | Cursor is malformed. Drop it and restart from page 1. |
Webhooks
Receive signed event POSTs when invoices change state. Stripe-style HMAC signature.
Drupd POSTs signed JSON to your endpoint when invoices change state. Use them to close the loop in your downstream system without polling: update a WooCommerce order on invoice.paid, log a CRM activity on invoice.viewed, kick off a follow-up workflow on invoice.overdue.
Events
| Event | Fires when |
|---|---|
| invoice.created | Any new invoice row is inserted (draft or sent). |
| invoice.sent | An invoice is sent for the first time. |
| invoice.viewed | The client opens the hosted invoice page. |
| invoice.paid | The invoice is marked paid in full. |
| invoice.partial | A partial payment is recorded. |
| invoice.cancelled | The invoice is cancelled. |
| invoice.overdue | The cron flip after the due date passes. |
| invoice.test | Manually triggered from the "Send test event" button. |
New events ship as additive changes, so your handler should ignore unknown event types rather than fail.
Delivery order is not guaranteed. Two events for the same invoice can arrive out of order (invoice.sent may land before invoice.created), so don't build state machines on arrival order; use the created_at inside each payload to sequence events on your side.
Payload
{
"id": "01H…",
"object": "event",
"type": "invoice.paid",
"created_at": "2026-05-18T12:00:00Z",
"data": {
"object": { /* the invoice object (same field shape as GET /v1/invoices/{id}, see note) */ },
"client": {
"object": "client",
"id": "9e1c…",
"name": "Acme Studio",
"email": "billing@acme.example",
"company_name": "Acme Studio Ltd"
}
}
}
The invoice object lives under data.object. It is the same field shape as GET /v1/invoices/{id} with two differences: it does not include the line_items array, and hosted_url / pdf_url are null (webhooks are dispatched from a background context with no request origin). Treat it as a snapshot at the moment of the event: to get the current state, the line items, or the hosted/PDF URLs, hit GET /v1/invoices/{id} with the API key.
data.client carries a minimal client subset (id, name, email, company name) so a receiver can dispatch (CRM record, fulfilment) without an extra authenticated round-trip. The payload is snapshotted at the moment the event is emitted, so data.client is null only if the client had already been deleted when the event was emitted; a deletion that happens afterward does not affect deliveries already queued.
Test events (type: "invoice.test") carry synthetic ids prefixed with test_ (e.g. client_id: "test_client_0001"). A defensive receiver can detect test payloads in one check without colliding with real UUIDs.
Verifying the signature
Every request carries a Drupd-Signature header in Stripe's format:
Drupd-Signature: t=1716033600,v1=e5b4...c1f9
Compute HMAC-SHA256(secret, "<t>.<raw-body>") in hex and compare to v1 in constant time.
The t value is the moment of this delivery attempt, not the original event. A retried delivery carries a fresh t; verify against the current clock, never against created_at inside the body.
Alongside Drupd-Signature, every delivery carries these headers so you can route and dedupe without parsing the body first:
| Header | Value |
|---|---|
| Drupd-Event-Type | The event type, e.g. invoice.paid. |
| Drupd-Event-Id | The event id (matches event.id in the body). Use it as the dedupe key (see Idempotency on your side). |
| Drupd-Delivery-Id | The id of this specific delivery attempt. |
Node.js
import crypto from "node:crypto";
function verifyDrupdWebhook(secret, signatureHeader, rawBody) {
const parts = Object.fromEntries(
signatureHeader.split(",").map((kv) => kv.split("="))
);
const t = parts.t;
const sig = parts.v1;
if (!t || !sig) return false;
const ageSeconds = Math.floor(Date.now() / 1000) - Number.parseInt(t, 10);
if (Math.abs(ageSeconds) > 5 * 60) return false; // replay window
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const sigBuf = Buffer.from(sig, "hex");
const expectedBuf = Buffer.from(expected, "hex");
// timingSafeEqual throws on length mismatch, so guard before comparing.
if (sigBuf.length !== expectedBuf.length) return false;
return crypto.timingSafeEqual(sigBuf, expectedBuf);
}
PHP (WordPress plugin friendly)
function drupd_verify_webhook($secret, $signature_header, $raw_body) {
parse_str(str_replace(',', '&', $signature_header), $parts);
$t = $parts['t'] ?? null;
$sig = $parts['v1'] ?? null;
if (!$t || !$sig) return false;
if (abs(time() - intval($t)) > 5 * 60) return false;
$expected = hash_hmac('sha256', $t . '.' . $raw_body, $secret);
return hash_equals($sig, $expected);
}
The raw_body must be the exact bytes received, before any JSON decoding. In WordPress that means reading php://input once and not letting any other plugin re-encode it.
Delivery + retries
The signing secret is the only way to authenticate a webhook. We do not pin source IPs; Cloudflare Workers can run from any data center. Trust the signature.
| Behavior | Default |
|---|---|
| Receiver timeout | 10 seconds. |
| Success | Any 2xx response. |
| Retry on failure | 5s, 30s, 5m, 30m, 2h, 12h after the initial attempt (up to 6 retries). |
| Auto-disable | After 7 consecutive failed delivery attempts, the endpoint flips to degraded. Re-enable by sending PATCH /v1/webhook_endpoints/{id} with {"status": "active"} (this also clears the failure counter), or from Settings. |
| Max payload | 1 MiB. Larger events are dropped server-side, not truncated. |
| Stored response excerpt | 8 KiB. We keep this for debugging. |
Deliveries are also crash-safe: if a dispatch worker dies mid-attempt, the stuck in-flight delivery is reclaimed and retried automatically after about 5 minutes. You may receive that delivery later than expected, but it is never silently lost.
Idempotency on your side
Drupd may deliver the same event more than once: under load, a network blip mid-response can leave us uncertain whether you received it, so we retry to be safe. Treat event.id as the dedupe key. Store the IDs you have processed (with a 7-day TTL) and skip duplicates.
Endpoints
| Method | Path |
|---|---|
| GET | /v1/webhook_endpoints |
| POST | /v1/webhook_endpoints |
| GET | /v1/webhook_endpoints/{id} |
| PATCH | /v1/webhook_endpoints/{id} |
| DELETE | /v1/webhook_endpoints/{id} |
| POST | /v1/webhook_endpoints/{id}/test |
| GET | /v1/webhook_endpoints/{id}/deliveries |
| GET | /v1/webhook_endpoints/{id}/deliveries/{deliveryId} |
Create
curl -X POST https://api.drupd.com/v1/webhook_endpoints \
-H "Authorization: Bearer drupd_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/drupd",
"description": "WooCommerce order fulfillment",
"events": ["invoice.paid", "invoice.viewed"]
}'
The response includes signing_secret exactly once. After creation, only signing_secret_last4 is visible; if you lose the secret, delete the endpoint and create a new one.
Each workspace can hold at most 10 webhook endpoints. Past the cap, creation returns 409 webhook.endpoint_limit_reached; delete an endpoint to make room.
Update
PATCH /v1/webhook_endpoints/{id} accepts any subset of url, description, events, and status. For status, you may set active or disabled; setting active also clears the consecutive-failure counter. A GET may report status: "degraded"; that value is set by the delivery system, not by you. PATCH accepts it on input (so a read-modify-write that echoes the whole object back doesn't error) but ignores it; to bring a degraded endpoint back online, send status: "active".
URL restrictions
url must be https://. Drupd rejects obvious unsafe hostnames and address literals at creation: localhost, loopback (127.0.0.0/8), private IPv4 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), link-local IPv4 (169.254.0.0/16), IPv6 loopback, IPv6 unique-local (fc00::/7), and IPv4-mapped variants that resolve into blocked IPv4 ranges. This is the API-level SSRF guard; do not use private or internal webhook targets.
Test
POST /v1/webhook_endpoints/{id}/test enqueues a synthetic invoice.test event with a dummy invoice payload. Useful for verifying signature verification before wiring it into live traffic.
Test events are rate limited: each workspace can queue at most 10 test deliveries per minute. Past that the endpoint returns 429 webhook.test_rate_limited with a Retry-After header.
Deliveries
GET /v1/webhook_endpoints/{id}/deliveries returns recent attempts (up to limit, default 50, max 100) with response status, latency, and (if available) the receiver's response excerpt. Delivery history is kept without a fixed retention window today; expect a future cleanup policy once we have production data. Uses standard cursor pagination: pass ?cursor=… from the previous response's meta.next_cursor to fetch the next page; stop when meta.has_more is false.
GET /v1/webhook_endpoints/{id}/deliveries/{deliveryId} fetches a single delivery directly, useful for polling a synthetic test event after POST /test hands back a delivery_id.
Common errors
| Code | When |
|---|---|
| webhook_endpoint.not_found | The endpoint is in another workspace, or does not exist. |
| webhook_delivery.not_found | The delivery does not exist for this endpoint. |
| webhook.invalid_url | The url is not https://, or targets a blocked hostname or address range (400). See URL restrictions above. |
| webhook.endpoint_limit_reached | The workspace already has 10 endpoints (409). Delete one to add another. |
| webhook.test_rate_limited | More than 10 test deliveries queued in a minute (429). Carries Retry-After. |
Clients
Create, list, fetch, update, and delete the clients you bill.
Clients are the people and companies you invoice. You can create one inline while creating an invoice (the client object on POST /v1/invoices), or manage them directly with the endpoints here. Managing them directly is what lets an integration reconcile its own records against Drupd: look a client up, reuse the client_id on future invoices, and avoid creating a duplicate client on every order.
Create a client
POST /v1/clients
curl -X POST https://api.drupd.com/v1/clients \
-H "Authorization: Bearer drupd_live_..." \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"name": "Acme Studio",
"email": "billing@acme.example",
"company_name": "Acme Studio Ltd",
"address_line1": "1 Plaza de la Independencia",
"city": "Madrid",
"country": "Spain",
"default_currency": "EUR",
"email_cc": "ap@acme.example, owner@acme.example"
}'
email and billing_email are set, billing_email receives the invoice.name."United States"."USD".All fields except name are optional.
About email_cc
Invoices created through the API carry no inline CC of their own, so the CC on an API-sent invoice is resolved from this client's email_cc first, then the workspace default CC. Set email_cc here to have every invoice you send to this client copy those addresses. The stored value is normalized (trimmed, de-duplicated, capped at 2 recipients); reading the client back returns the normalized form.
Plan limits
Creating a client counts against your plan's client limit. Once the limit is reached, create calls return 402 client.limit_reached. Placeholder rows that the dashboard wizard creates for unsaved drafts are not counted and never appear in the list.
Response
{
"data": {
"object": "client",
"id": "9e1c…",
"org_id": "df94…",
"name": "Acme Studio",
"email": "billing@acme.example",
"billing_email": null,
"company_name": "Acme Studio Ltd",
"address_line1": "1 Plaza de la Independencia",
"address_line2": null,
"city": "Madrid",
"state": null,
"postal_code": null,
"country": "Spain",
"tax_id": null,
"website": null,
"default_currency": "EUR",
"default_tax_rate": "0",
"default_payment_terms": null,
"email_cc": "ap@acme.example, owner@acme.example",
"notes": null,
"created_at": "2026-06-12T12:00:00Z",
"updated_at": "2026-06-12T12:00:00Z"
},
"object": "client",
"request_id": "req_…"
}
List clients
GET /v1/clients?limit=25&cursor=…
curl https://api.drupd.com/v1/clients?limit=25 \
-H "Authorization: Bearer drupd_live_..."
meta.next_cursor from the previous page.Returns clients newest-first, wrapped in a list with meta.has_more and meta.next_cursor, the same pagination shape as GET /v1/invoices.
Get a single client
GET /v1/clients/{id}
The id is the client UUID (the id from a create response or client_id on an invoice).
Update a client
PATCH /v1/clients/{id}
Send only the fields you want to change. Field rules and limits match the create endpoint. A body with no recognized fields returns 400.
curl -X PATCH https://api.drupd.com/v1/clients/9e1c… \
-H "Authorization: Bearer drupd_live_..." \
-H "Content-Type: application/json" \
-d '{ "billing_email": "ap@acme.example", "email_cc": "owner@acme.example" }'
Returns the full updated client.
Delete a client
DELETE /v1/clients/{id}
Soft-deletes the client. A client that still has invoices cannot be deleted: the call returns 409 client.in_use with the invoice count. Delete or reassign those invoices first.
{
"data": { "object": "client", "id": "9e1c…", "deleted": true },
"object": "client",
"request_id": "req_…"
}
Common errors
| Code | When |
|---|---|
| client.not_found | The client is in another workspace, or does not exist. |
| client.limit_reached | Your plan's client limit is reached (402). Upgrade to add more. |
| client.in_use | DELETE on a client that still has invoices (409). The message includes the count. |
| request.invalid | A field failed validation (bad email, over-long string, bad currency code), or the id is not a UUID. Inspect param. |
Invoices
Create, list, fetch, and send invoices. The core of the API surface.
Invoices are the centerpiece. The same endpoint can either save a draft (the dashboard's default) or create-and-send in one call (the integration default).
Create an invoice
POST /v1/invoices
curl -X POST https://api.drupd.com/v1/invoices \
-H "Authorization: Bearer drupd_live_..." \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"client": {
"name": "Acme Studio",
"email": "billing@acme.example",
"company_name": "Acme Studio Ltd",
"address_line1": "1 Plaza de la Independencia",
"city": "Madrid",
"country": "Spain",
"default_currency": "EUR"
},
"currency": "EUR",
"line_items": [
{
"description": "Quarterly retainer",
"quantity": 1,
"unit_price": 4200
}
],
"send": true
}'
Client resolution
Pass exactly one of:
client_id: an existing client UUID. Drupd validates it belongs to this workspace.client: an inline object. Drupd inserts a new client row using these details. To reuse a client on later invoices, capture the returnedclient_idand pass it next time.
Inline client fields
The inline client object accepts:
| Field | Type | Notes |
|---|---|---|
| id | uuid | Optional, find-or-create: if it matches an existing client in your workspace, the invoice attaches to that client and the stored record wins (the other inline fields do not overwrite it). If it matches nothing, a new client is created under this id. Omit it to always create a new client. |
| name | string | Required. 1–160 chars. |
| string | Valid email. | |
| billing_email | string | Valid email. Preferred over email as the send recipient. |
| company_name | string | Max 160 chars. |
| address_line1 | string | Max 255 chars. |
| address_line2 | string | Max 255 chars. |
| city | string | Max 120 chars. |
| state | string | Max 120 chars. |
| postal_code | string | Max 40 chars. |
| country | string | Max 120 chars. Empty or omitted defaults to "United States". |
| tax_id | string | Max 80 chars. |
| website | string | Max 255 chars. |
| default_currency | string | 3-letter ISO code, normalized to uppercase. Defaults to "USD" when omitted. |
| default_tax_rate | string|number | Non-negative decimal. |
| default_payment_terms | string | Max 64 chars. |
| notes | string | Free text. |
All fields except name are optional.
Required fields
- At least one
line_itemsentry (max 200). - A client (via either resolution).
- When
send: true, the client must have a deliverable address: eitherbilling_emailoremailqualifies, andbilling_emailis preferred as the recipient when both are present. With neither, the endpoint returns400 invoice.client_email_required.
Optional behavior
send: truetriggers delivery: the invoice is committed as sent and theinvoice.sentwebhook fires immediately; the PDF renders in the background and the client email goes out with the PDF attached once rendered, typically within seconds of the API response. Subject to the API send caps (30/minute, 200/day, and 500/month per Pro workspace).invoice_number(max 64 chars),issue_date,due_date,payment_terms(max 32 chars) all default to workspace settings if omitted. Omitinvoice_numberto auto-assign the next sequential number. A suppliedinvoice_numberthat already exists in the workspace, including on a deleted invoice, is rejected with409 invoice.number_conflictrather than creating a duplicate.currencymust match/^[A-Za-z]{3}$/and is normalized to uppercase ("eur"becomes"EUR"). Defaults to the workspace currency when omitted. The same rule applies to the inline client'sdefault_currency.tax_modeis one ofnone,exclusive, orinclusive. Defaults to the workspace setting.notes(max 10000 chars),footer(max 2000), andpayment_instructions(max 2000) override the workspace defaults for the rendered invoice.payment_link_urlmust be a valid absolute URL, max 500 chars.pdf_templatedefaults to the workspace default; passing a template not included in your plan silently falls back to the default.- Creating an invoice (with or without
send) counts against your plan's monthly invoice limit. Once the limit is reached, create calls return402 invoice.monthly_limit_reached.
Line item fields
Each entry in line_items accepts:
| Field | Type | Default | Notes |
|---|---|---|---|
| description | string | none | Required. 1–500 chars. |
| details | string | none | Optional secondary line. Max 2000 chars. |
| type | enum | qty | One of qty, hours, days, flat, subscription, discount. |
| quantity | number | 1 | A count, not money. Non-negative, max 1,000,000. |
| unit_price | string|number | 0 | Decimal string (canonical) or number. Magnitude capped at 1e12. |
| tax_rate | string|number | 0 | Percentage, 0–100. |
| tax_status | enum | custom | Tax treatment, distinct from the numeric rate. One of custom, reduced, zero_rated, exempt, reverse_charge. |
tax_status distinguishes cases a bare rate can't express: for example a zero_rated line versus an exempt one (both resolve to 0%). The numeric tax_rate stays the source of truth for the amount charged, but the fixed-rate treatments (zero_rated, exempt, reverse_charge) force tax_rate to 0 on save regardless of the value sent, so the printed treatment and the charged amount can never disagree. custom (the default) means a plain entered rate with no special treatment. Every line item in responses includes tax_status.
Response
{
"data": {
"object": "invoice",
"id": "f25a…",
"public_id": "inv_a8f1b2c3d4e5",
"invoice_number": "INV-2026-0042",
"status": "sent",
"client_id": "9e1c…",
"org_id": "df94…",
"currency": "EUR",
"currency_minor_unit": 2,
"subtotal": "4200.00",
"tax_total": "0.00",
"discount_amount": "0.00",
"total": "4200.00",
"balance_due": "4200.00",
"amount_paid": "0.00",
"hosted_url": "https://drupd.com/i/inv_a8f1b2c3d4e5",
"pdf_url": "https://drupd.com/api/pdf?publicId=inv_a8f1b2c3d4e5",
"sent_at": "2026-05-18T12:00:00Z",
"created_at": "2026-05-18T12:00:00Z",
"line_items": [ /* same shape as GET /v1/invoices/{id}, see below */ ]
},
"object": "invoice",
"request_id": "req_…"
}
The 201 response includes the line_items array in the same shape as GET /v1/invoices/{id}, so you don't need an immediate re-fetch after creating.
hosted_url is safe to share with the client: it is a public, no-auth invoice page. pdf_url lazily renders the PDF the first time it is requested. For draft invoices pdf_url is null; it becomes available once the invoice is sent. For API sends the same PDF is also attached to the client email: it renders in the background after the API responds, and the email goes out once the render completes (typically within seconds).
List invoices
GET /v1/invoices?limit=25&cursor=…&status=…
curl https://api.drupd.com/v1/invoices?limit=25 \
-H "Authorization: Bearer drupd_live_..."
meta.next_cursor from the previous page.draft, sent, viewed, partial, paid, overdue, cancelled, lost.The response wraps a list:
{
"data": [ /* … invoices … */ ],
"object": "list",
"meta": { "has_more": true, "next_cursor": "eyJjcm…" },
"request_id": "req_…"
}
Get a single invoice
GET /v1/invoices/{id}
The id can be either the UUID or the public_id (inv_…). The response includes a line_items array alongside the invoice object:
{
"object": "invoice_line_item",
"id": "3c6f0d2a-…",
"invoice_id": "f25a…",
"description": "Quarterly retainer",
"details": null,
"type": "qty",
"quantity": "1",
"unit_price": "4200.00",
"tax_rate": "0",
"tax_status": "custom",
"amount": "4200.00",
"sort_order": 0
}
Send an existing draft
POST /v1/invoices/{id}/send
Use this for the two-step flow: create a draft, inspect it from another system, then send.
curl -X POST https://api.drupd.com/v1/invoices/f25a…/send \
-H "Authorization: Bearer drupd_live_..."
Subject to the same send caps as send: true on creation. Returns the updated invoice. Delivery works the same way too: the response returns as soon as the invoice is committed as sent, and the client email (with the PDF attached) goes out once the background render completes, typically within seconds.
Only draft invoices can be sent. A non-draft or locked invoice returns 409 invoice.not_draft. A draft that is missing data required to send (client details, client email, or line items) returns 422 invoice.send_precondition_failed; the param field names what's missing. Fix the data and retry.
This endpoint is automatically idempotent by invoice id: a retry without Idempotency-Key will never send the email twice or double-count against the send cap. You can still pass an explicit Idempotency-Key when you want the standard 24-hour byte-identical replay response or want to control the dedupe scope yourself.
Lifecycle actions
These move an invoice through its lifecycle. Each returns the updated invoice (the same shape as GET /v1/invoices/{id}), and {id} accepts the UUID or public_id.
Cancel
POST /v1/invoices/{id}/cancel
Voids an outstanding invoice and, if it had already been sent, emails the client a cancellation notice. A paid invoice cannot be cancelled (409 invoice.not_cancellable). Cancelling an already-cancelled invoice is a no-op success. Fires the invoice.cancelled webhook.
Mark as lost
POST /v1/invoices/{id}/mark_lost
Marks an outstanding invoice (sent, viewed, partial, or overdue) as no longer expected to be paid. Any other state returns 409 invoice.not_lostable. Marking an already-lost invoice is a no-op success.
Duplicate
POST /v1/invoices/{id}/duplicate
Creates a new draft copy of any invoice: a fresh public_id, a -COPY invoice number (suffixed if that collides), zeroed payment state, and all line items copied. Returns 201 with the new invoice (including line_items). Fires invoice.created.
Delete
DELETE /v1/invoices/{id}
Soft-deletes a draft or cancelled invoice. A sent or paid invoice is a billing record and cannot be deleted (409 invoice.not_deletable); cancel it first if you need it out of the active list.
{
"data": { "object": "invoice", "id": "f25a…", "deleted": true },
"object": "invoice",
"request_id": "req_…"
}
Updating an invoice in place (PATCH /v1/invoices/{id}) is not yet supported. To change a sent invoice, cancel it and create a new one; to change a draft, delete it and recreate, or duplicate and edit the copy.
Scheduling (Pro)
Schedule a draft to send automatically at a future time instead of sending it now. Scheduling is a Pro feature; on a non-Pro workspace these return 402 plan.schedule_send_unavailable.
Schedule (or reschedule)
POST /v1/invoices/{id}/schedule
curl -X POST https://api.drupd.com/v1/invoices/inv_a8f1b2c3d4e5/schedule \
-H "Authorization: Bearer drupd_live_..." \
-H "Content-Type: application/json" \
-d '{ "scheduled_send_at": "2026-07-01T09:00:00Z" }'
The invoice must be a draft (409 invoice.not_schedulable otherwise) and must be sendable, it needs a client email and at least one line item, since the scheduled run emails it without further input (422 invoice.send_precondition_failed if not). scheduled_send_at must be a future ISO-8601 datetime (422 invoice.schedule_in_past otherwise). Calling this again with a new time reschedules. Scheduling is checked against the same send caps as an immediate send, so the call can return the send-cap 429s (rate_limit.api_send_burst, rate_limit.api_send_daily_exceeded, rate_limit.api_monthly_send_exceeded) and the response carries the X-Drupd-Sends-Remaining-* headers; the send itself counts toward the caps when it actually fires. Returns the updated invoice with scheduled_send_at set.
Cancel a scheduled send
POST /v1/invoices/{id}/cancel_schedule
Clears the pending send and leaves the invoice a plain draft. Returns 409 invoice.not_scheduled if nothing was scheduled.
Follow-ups (Pro)
POST /v1/invoices/{id}/follow_up sends a reminder email for an outstanding invoice, and POST /v1/invoices/{id}/follow_up/pause pauses or resumes the automated reminder schedule per invoice. Both have their own page: see Follow-ups for request fields, template fallback behavior, and errors.
Common errors
| Code | When |
|---|---|
| invoice.client_required | Neither client_id nor client was provided. |
| invoice.client_email_required | send: true with no client email or billing_email. |
| invoice.not_found | The invoice is in another workspace, or does not exist. |
| invoice.client_ambiguous | Both client_id and client supplied; pass exactly one. |
| invoice.monthly_limit_reached | Your plan's monthly invoice limit is reached (402). Upgrade to create more. |
| invoice.number_conflict | The supplied invoice_number already exists in the workspace, including on a deleted invoice (409). |
| invoice.not_draft | POST /v1/invoices/{id}/send on an invoice that is not a draft, or is locked (409). |
| invoice.send_precondition_failed | The draft is missing client details, a client email, or line items (422). Inspect param. |
| invoice.not_cancellable | POST /v1/invoices/{id}/cancel on a paid invoice (409). |
| invoice.not_lostable | POST /v1/invoices/{id}/mark_lost on an invoice that is not outstanding (409). |
| invoice.not_deletable | DELETE /v1/invoices/{id} on a sent or paid invoice (409). Cancel it first. |
| invoice.not_outstanding | POST /v1/invoices/{id}/payments or /follow_up on an invoice that is not outstanding (422). |
| invoice.not_schedulable | POST /v1/invoices/{id}/schedule on a non-draft invoice (409). |
| invoice.schedule_in_past | scheduled_send_at is not in the future (422). |
| invoice.not_scheduled | POST /v1/invoices/{id}/cancel_schedule when nothing is scheduled (409). |
| plan.schedule_send_unavailable | Scheduling on a non-Pro workspace (402). |
| plan.follow_ups_unavailable | Sending a follow-up on a non-Pro workspace (402). |
| follow_up.send_failed | The follow-up was accepted but the email provider rejected it (502). |
| invoice.dates_required | A supplied issue_date or due_date is empty or not a real calendar date (422). Omit the field to use the workspace default, or pass a valid YYYY-MM-DD. The param field names the offending date. |
| rate_limit.api_send_burst | 30 API sends/min per workspace. |
| rate_limit.api_send_daily_exceeded | 200 API sends/24h per workspace. |
| rate_limit.api_monthly_send_exceeded | Monthly API send cap reached (500 on Pro). |
Payments
Record a payment against an invoice to settle it in full or in part.
Record money received against an outstanding invoice. One endpoint covers both full settlement and partial payments: Drupd reconciles the amount against the remaining balance and moves the invoice to paid or partial accordingly.
Drupd does not keep a separate per-payment ledger: recording a payment updates the invoice itself (amount_paid, status, and paid_at). There is therefore no standalone payment object to fetch; the updated invoice is the source of truth and is returned from the call.
Record a payment
POST /v1/invoices/{id}/payments
The {id} is the invoice UUID or its public_id (inv_…).
curl -X POST https://api.drupd.com/v1/invoices/inv_a8f1b2c3d4e5/payments \
-H "Authorization: Bearer drupd_live_..." \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "amount": 4200 }'
Behavior
amountis accumulated onto any payment already recorded. A second payment of300on a1000invoice that already had300recorded lands at600paid /400due.- The amount may not exceed the remaining balance. Recording
600against a500invoice (or300when only200remains) returns422 invoice.payment_exceeds_balance; the error message includes the remaining balance. Record the exact remaining amount to settle. - If the amount exactly settles the remaining balance, the invoice is settled:
statusbecomespaid,balance_duebecomes0,paid_atis stamped, and the client is emailed a payment receipt (only if the invoice had been sent). - Otherwise the invoice becomes
partial:amount_paidandbalance_dueare updated andpartial_atis stamped. No receipt email is sent for a partial. - A matching webhook fires:
invoice.paidon full settlement,invoice.partialotherwise.
Idempotency
Each call accumulates, so a blind retry would double-count. Recording a payment is auto-idempotent: even without an Idempotency-Key, Drupd synthesizes one from the invoice id plus a hash of the request body, so a retried delivery (a crashed worker, a network timeout) of the same payment returns the original response without recording it twice. Two different payments (different amount) are independent. To record two intentionally-identical payments, pass a distinct explicit Idempotency-Key on the second. See Idempotency.
Preconditions
Only outstanding invoices accept payments: sent, viewed, partial, or overdue. Recording against a draft, paid, cancelled, or lost invoice returns 422 invoice.not_outstanding. Send the invoice first (POST /v1/invoices/{id}/send) before recording payment.
Response
Returns the updated invoice in the same shape as GET /v1/invoices/{id}:
{
"data": {
"object": "invoice",
"id": "f25a…",
"public_id": "inv_a8f1b2c3d4e5",
"status": "paid",
"total": "4200.00",
"amount_paid": "4200.00",
"balance_due": "0.00",
"paid_at": "2026-06-12T12:00:00Z",
"…": "…"
},
"object": "invoice",
"request_id": "req_…"
}
Common errors
| Code | When |
|---|---|
| invoice.not_found | The invoice is in another workspace, or does not exist. |
| invoice.not_outstanding | The invoice is not in a state that accepts payments (it is a draft, already paid, cancelled, or lost) (422). |
| invoice.payment_exceeds_balance | amount is larger than the remaining balance (422). The message names the remaining balance. |
| request.invalid | amount is missing, not a number, or not greater than 0. |
Follow-ups
Send reminder emails for outstanding invoices and control the automated reminder schedule per invoice.
Follow-ups are reminder emails for invoices that have gone out but not been paid. Drupd runs an automated reminder schedule per invoice; the API lets you fire a reminder on demand and pause or resume the automated schedule for a specific invoice.
Both endpoints are Pro-gated: a non-Pro workspace gets 402 plan.follow_ups_unavailable.
Follow-ups only apply to outstanding invoices: sent, viewed, partial, or overdue. A draft, paid, cancelled, or lost invoice returns 422 invoice.not_outstanding. As everywhere else, {id} accepts either the invoice UUID or its inv_ public id.
Send a follow-up now
POST /v1/invoices/{id}/follow_up
curl -X POST https://api.drupd.com/v1/invoices/inv_a8f1b2c3d4e5/follow_up \
-H "Authorization: Bearer drupd_live_..." \
-H "Content-Type: application/json" \
-d '{}'
| Field | Type | Notes |
|---|---|---|
| subject | string | Optional. Max 300 chars. |
| body | string | Optional. Max 10,000 chars. |
Both fields are optional, and a blank string counts as omitted. Whatever you leave out is filled from the workspace's reminder template, resolved the same way the dashboard's compose modal resolves it: an explicit value in the request wins, then the client's template override, then the workspace's saved reminder copy, then the built-in default. So -d '{}' sends a fully templated reminder with zero authoring.
The email goes to the client's billing_email if set, otherwise email, with the workspace CC rules applied, and the invoice PDF attached.
{
"data": { "invoice_id": "c7386e1c-…", "sent": true },
"object": "follow_up",
"request_id": "req_…"
}
The send is synchronous: a 200 means the email provider accepted the reminder. If the provider rejects it, the call returns 502 follow_up.send_failed. In an environment with no email provider configured (a preview deploy, for example) it returns 503 email.not_configured instead.
A manual follow-up stamps the invoice's last-reminded timestamp (so activity feeds and the dashboard agree) but does not pause or reschedule the automated reminders. Use the pause endpoint below for that.
Pause / resume the automated schedule
POST /v1/invoices/{id}/follow_up/pause
curl -X POST https://api.drupd.com/v1/invoices/inv_a8f1b2c3d4e5/follow_up/pause \
-H "Authorization: Bearer drupd_live_..." \
-H "Content-Type: application/json" \
-d '{ "paused": true, "note": "client asked for more time" }'
| Field | Type | Notes |
|---|---|---|
| paused | boolean | Required. true halts automated reminders for this invoice; false resumes them. |
| note | string | Optional. Max 500 chars. Shown in the dashboard next to the paused state. |
{
"data": {
"invoice_id": "c7386e1c-…",
"follow_up_paused": false,
"next_follow_up_at": "2026-06-26T09:00:00.000Z"
},
"object": "follow_up_schedule",
"request_id": "req_…"
}
Resuming recomputes next_follow_up_at from the invoice's current position in the schedule. next_follow_up_at is null while paused or when no further automated reminder is due.
One thing to know about the automated schedule: it is snapshotted onto the invoice when the invoice is sent. Changing the workspace's reminder schedule later affects future sends only; it never re-queues reminders for invoices that already went out. The pause endpoint is the per-invoice control for those.
Common errors
| Code | When |
|---|---|
| plan.follow_ups_unavailable | The workspace is not on a paid Pro plan (402). |
| invoice.not_found | The invoice is in another workspace, or does not exist. |
| invoice.not_outstanding | The invoice is a draft, already paid, cancelled, or lost (422). |
| invoice.client_email_required | The client has no email or billing_email to send to (400). |
| follow_up.send_failed | The email provider rejected the reminder (502). Retry later; the reminder did not go out. |
| email.not_configured | Email sending is not configured in this environment (503). |
Recurring invoices
Schedule an invoice to regenerate on a cadence from a template.
A recurring schedule regenerates a new invoice from a template invoice on a fixed cadence (and can auto-send each one). Recurring invoices are a Pro feature: on a non-Pro workspace these endpoints return 402 plan.recurring_unavailable.
The template must be an invoice that has already been sent or paid (sent, viewed, partial, overdue, or paid), Drupd copies its line items, branding, and payment details into each generated invoice. Create a normal invoice first, send it, then point a schedule at it.
Create a schedule
POST /v1/recurring_invoices
curl -X POST https://api.drupd.com/v1/recurring_invoices \
-H "Authorization: Bearer drupd_live_..." \
-H "Content-Type: application/json" \
-d '{
"template_invoice_id": "f25a…",
"frequency": "monthly",
"start_date": "2026-07-01",
"auto_send": true
}'
weekly, biweekly, monthly, quarterly, annually.YYYY-MM-DD. The first run date.YYYY-MM-DD. Must be after start_date. Omit for an open-ended schedule.false. When true, each generated invoice is emailed to the client automatically; otherwise it's left as a draft for you to review.Response
{
"data": {
"object": "recurring_invoice",
"id": "a7e4…",
"template_invoice_id": "f25a…",
"template_invoice_number": "INV-2026-0042",
"client_id": "9e1c…",
"client_name": "Acme Studio Ltd",
"frequency": "monthly",
"status": "active",
"start_date": "2026-07-01",
"end_date": null,
"next_run_at": "2026-07-01",
"auto_send": true,
"generated_count": 0,
"last_generated_at": null,
"currency": "EUR",
"total": "4200.00"
},
"object": "recurring_invoice",
"request_id": "req_…"
}
List schedules
GET /v1/recurring_invoices
Returns every schedule for the workspace, newest first.
Get / update / delete
GET /v1/recurring_invoices/{id}: fetch one.PATCH /v1/recurring_invoices/{id}: changefrequency,end_date, orauto_send. Changing the frequency on an active schedule recomputesnext_run_at. Send at least one field.DELETE /v1/recurring_invoices/{id}: cancel and remove the schedule. Invoices it already generated are unaffected.
Pause / resume
POST /v1/recurring_invoices/{id}/pause: stop generating new invoices, leaving the schedule on file.POST /v1/recurring_invoices/{id}/resume: restart generation;next_run_atis recomputed from now.
Both return the updated schedule.
Common errors
| Code | When |
|---|---|
| plan.recurring_unavailable | The workspace is not on Pro (402). |
| recurring_invoice.not_found | The schedule is in another workspace, or does not exist. |
| invoice.not_found | The template_invoice_id does not resolve to an invoice in this workspace. |
| recurring.template_not_eligible | The template invoice is still a draft (must be sent or paid) (422). |
| recurring.invalid_date_range | end_date is not after start_date (422). |