Reference · v1

An API for invoicing as a primitive.

Seven docs, one scrollable page. Every endpoint your integrations need, with the limits and contracts they hit.

Base URL

drupd.com/v1

Auth

Bearer · scoped

Rate limit

600 / min / key

Idempotency

24h replay

Webhooks

HMAC-SHA256

Versioning

URL · /v1

01 / 07https://drupd.com/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://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/invoices with send: true creates 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-Key header 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.overdue and similar events so your downstream system (WooCommerce order fulfillment, CRM record, accounting export) closes the loop without polling.

Base URL

https://drupd.com/v1

The API is served from the same origin as the marketing site. There is no separate api.drupd.com subdomain.

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
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 with currency_minor_unit if 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) use YYYY-MM-DD and 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_endpoints returns the full capped endpoint list for the workspace.
  • Every response carries Drupd-Request-Id: req_… in the header and request_id in 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.

02 / 07GET/v1/me

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://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.

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.

ScopeCan readCan writeUse for
fullyesyesDefault. Backend integrations that issue invoices.
readyesnoReporting, 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_at and last_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://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.

03 / 07HEADERDrupd-Request-Id

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

HTTPtypeWhat it means
400invalid_request_errorValidation, malformed JSON, unknown enum value.
401authentication_errorMissing, malformed, or revoked Bearer.
402plan_limit_errorWorkspace is not on a paid Pro plan (trials excluded).
403permission_errorKey scope does not allow this endpoint.
404not_found_errorResource missing or not visible to this key's workspace.
405invalid_request_errorEndpoint exists but not for this HTTP method. Carries an Allow header listing the methods this URL accepts.
409idempotency_errorSame Idempotency-Key, different body, or still in flight.
409invalid_request_errorResource cap reached (e.g. 10 webhook endpoints/org).
413invalid_request_errorRequest body exceeded the 1 MiB cap.
429rate_limit_errorPer-key request limit, per-org burst, per-org daily cap, or per-org monthly cap exceeded.
500internal_errorBug on our side. Include the request_id if you contact support.

Common codes

CodeWhen
auth.missing_bearerNo Authorization header.
auth.malformed_bearerHeader present but not Bearer drupd_live_….
auth.invalidHash unknown, key revoked, or workspace deleted.
auth.scope_deniedread key called a write endpoint.
plan.pro_requiredWorkspace is on Free, on a trial, or its trial expired — the API needs a paid Pro plan.
rate_limit.exceeded600 req/min per key.
rate_limit.api_send_burst30 API sends/min per workspace.
rate_limit.api_send_daily_exceeded200 API sends/24h per workspace. Email sales@drupd.com to raise it.
rate_limit.api_monthly_send_exceededAPI monthly send cap reached (500 on Pro). Email sales@drupd.com to raise it.
idempotency.payload_mismatchSame key, different body.
idempotency.in_flightA request with this key is still running. Retry once the original completes.
idempotency.invalid_keyIdempotency-Key was blank or longer than 255 characters.
request.invalidGeneric validation. Inspect param.
request.cursor_invalidPagination cursor is malformed.
request.payload_too_largeRequest body exceeded the 1 MiB cap.
request.method_not_allowedEndpoint exists but not for this HTTP method.
request.unknown_endpointNo /v1 route matches the requested path.
invoice.client_requiredNeither client_id nor client was provided.
invoice.client_ambiguousBoth client_id and client were supplied — pass exactly one.
invoice.client_email_requiredsend: true requires the client to have an email.
invoice.not_foundThe invoice does not exist in this workspace.
client.not_foundThe client does not exist in this workspace.
webhook_endpoint.not_foundThe webhook endpoint does not exist in this workspace.
webhook_delivery.not_foundThe webhook delivery does not exist for this endpoint.
resource.not_foundReserved for cross-workspace deflection — wrong-org lookups land here so Drupd never reveals that a resource exists in another workspace. Per-resource codes (invoice.not_found, client.not_found, …) are used for in-workspace misses.
webhook.invalid_urlNon-HTTPS URL or an obviously unsafe webhook hostname/address literal was supplied.
webhook.endpoint_limit_reachedWorkspace has hit the 10 webhook endpoints/org cap. Delete one to add another.
server.internal_errorUnhandled server bug. Pass us the request_id.

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) and POST /v1/invoices/{id}/send carries:

HeaderMeaning
X-Drupd-Sends-Remaining-DaySends left in the rolling 24h window for this workspace.
X-Drupd-Sends-Remaining-MonthSends left in the current UTC calendar month for this workspace.

Use them to self-throttle before you hit a 429.

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.

04 / 07HEADERIdempotency-Key

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 header Drupd-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.

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 — 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:

EndpointAuto key
POST /v1/invoices/{id}/sendauto:send:{id}

A retry without an explicit key is safe — Drupd will not send the email twice or double-charge the send cap. 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.

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://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.

05 / 07GET/v1/invoices?cursor=…

Pagination

Opaque cursor pagination — never offset.

Large list endpoints use cursor pagination. Pass limit (default 25, max 100) and an optional opaque cursor from the previous response's meta.next_cursor.

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://drupd.com/v1/invoices?limit=50 \
  -H "Authorization: Bearer drupd_live_..."

# Next page
curl "https://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

CodeWhen
request.cursor_invalidCursor is malformed. Drop it and restart from page 1.
06 / 07HEADERDrupd-Signature

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

EventFires when
invoice.createdAny new invoice row is inserted (draft or sent).
invoice.sentAn invoice is sent for the first time.
invoice.viewedThe client opens the hosted invoice page.
invoice.paidThe invoice is marked paid in full.
invoice.partialA partial payment is recorded.
invoice.cancelledThe invoice is cancelled.
invoice.overdueThe cron flip after the due date passes.
invoice.testManually triggered from the "Send test event" button.

New events ship as additive changes — your handler should ignore unknown event types rather than fail.

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. It is null if the client row was deleted between the event firing and the dispatcher loading the payload.

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:

HeaderValue
Drupd-Event-TypeThe event type, e.g. invoice.paid.
Drupd-Event-IdThe event id (matches event.id in the body). Use it as the dedupe key (see Idempotency on your side).
Drupd-Delivery-IdThe 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 — 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.

BehaviorDefault
Receiver timeout10 seconds.
SuccessAny 2xx response.
Retry on failure5s, 30s, 5m, 30m, 2h, 12h after the initial attempt (up to 6 retries).
Auto-disableAfter 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 payload1 MiB. Larger events are dropped server-side, not truncated.
Stored response excerpt8 KiB. We keep this for debugging.

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

MethodPath
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://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.

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.

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.

07 / 07POST/v1/invoices

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://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 returned client_id and pass it next time.

Required fields

  • At least one line_items entry.
  • A client (via either resolution).
  • When send: true, the client must have a non-empty email. The endpoint returns 400 invoice.client_email_required otherwise.

Optional behavior

  • send: true triggers immediate delivery: PDF render, email send, and the invoice.sent webhook. Subject to the API send caps (30/minute, 200/day, and 500/month per Pro workspace).
  • invoice_number, issue_date, due_date, payment_terms all default to workspace settings if omitted.
  • pdf_template defaults to the workspace default; passing an unsupported template silently falls back.

Line item fields

Each entry in line_items accepts:

FieldTypeDefaultNotes
descriptionstringRequired. 1–500 chars.
detailsstringOptional secondary line.
typeenumqtyOne of qty, hours, days, flat, subscription, discount.
quantitynumber1A count, not money.
unit_pricestring|number0Decimal string (canonical) or number.
tax_ratestring|number0Percentage, 0–100.
tax_statusenumcustomTax 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 — e.g. 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"
  },
  "object": "invoice",
  "request_id": "req_…"
}

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.

List invoices

GET /v1/invoices?limit=25&cursor=…&status=…

curl https://drupd.com/v1/invoices?limit=25 \
  -H "Authorization: Bearer drupd_live_..."
limitintOPTIONALDEFAULT 25
Max 100.
cursorstringOPTIONAL
Opaque. Use meta.next_cursor from the previous page.
statusenumOPTIONAL
One of 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": "li_…",
  "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://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.

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.

Common errors

CodeWhen
invoice.client_requiredNeither client_id nor client was provided.
invoice.client_email_requiredsend: true with no client email.
invoice.not_foundThe invoice is in another workspace, or does not exist.
invoice.client_ambiguousBoth client_id and client supplied — pass exactly one.
rate_limit.api_send_burst30 API sends/min per workspace.
rate_limit.api_send_daily_exceeded200 API sends/24h per workspace.
rate_limit.api_monthly_send_exceededMonthly API send cap reached (500 on Pro).

End of reference · seven docs

Missing something? Email hello@drupd.com.