API and webhooks
A REST API over JSON plus signed webhooks, so your systems can open requests, read where they stand, and react the moment a decision lands. The API is in beta — details can still move, and we warn before anything breaks.
Authentication and tokens
- Open Settings → API in your organization (admins only).
- Create a token and copy it right away — it is shown only once.
- Send it with every call in the header:
Authorization: Bearer <token>.
Tokens belong to the organization, not to a person, and can be revoked at any time. An organization can hold up to 20 active tokens and make 60 API calls per minute.
Endpoints
All endpoints live under https://approvehub.app/api/v1 and speak plain JSON.
Workflows in detail
GET /api/v1/workflows/ pages through the organization's workflows: ?page=N (10 per page) and ?status=published or ?status=unpublished filter the list; the response carries workflows, page and total_count. Each entry is a summary — id, slug, name, description, status, version and timestamps.
POST /api/v1/workflows/ takes name (required), and optionally slug, description, publish, requesters, access, fields and stages. Field types: text, long_text, email, url, phone, number, date, time, date_range, checkbox, select, radio, file — each with its own settings object. A stage holds groups; a group is "any" (one approval is enough) or "all" (everyone must approve), and approvers are members referenced by id or email. "publish": true makes the workflow accept requests immediately; a published workflow needs at least one field.
A stage may carry skip_when: a list of condition sets that read the submitted form. When one whole set holds, the stage is approved automatically and its approvers are never asked. A set is {"match": "all"|"any", "rules": [...]}, sets are OR-ed together, and a rule is {"field": "<field slug>", "operator": "…", "values": ["…"]}. Operators taking a single operand read the first entry of values; filled and empty take none. A stage holds at most 5 sets and a set at most 10 rules.
Which operators a rule may use follows the field type: text-like fields take equals, not_equals, contains, not_contains, starts_with, ends_with; number, date and time take equals, not_equals, greater, greater_or_equal, less, less_or_equal; date_range takes starts_before, starts_after, ends_before, ends_after, longer_than, shorter_than; select and radio take equals, not_equals, any_of, none_of; checkbox takes any_of, all_of, none_of; file compares the number of attachments with equals, greater, less. Every type also takes filled and empty. Dates are YYYY-MM-DD, times are HH:MM, and option values must be options of the field itself.
A successful create answers 201 with the full workflow — including the generated slug, the field slugs you will submit values under, and the stage and field ids. GET /api/v1/workflows/{id}/ returns the same shape, and PUT /api/v1/workflows/{id}/ takes the same body as the create to change or publish it. version marks the definition requests were submitted against and only moves when the form or the approval chain changes; revision counts every save, including renames. Send the revision you read back in the PUT and the save is refused with 400 if someone edited the workflow meanwhile; leave it out to overwrite whatever is stored:
Requests in detail
GET /api/v1/requests/ pages through the organization's requests, newest first: ?page=N (20 per page), plus the filters ?workflow=<slug>, ?status=new|in_progress|completed|declined and ?requester=<member email>. The response carries requests, page and total_count.
POST /api/v1/requests/ opens a request: workflow is the slug, fields maps field slugs to values. Required fields must be present; unknown slugs are rejected.
Value format depends on the field type:
text,long_text,email,url,phone,select,radio,dateandtimetake a string (dates asYYYY-MM-DD).numbertakes a number.checkboxtakes an array of the chosen option values.date_rangetakes{"from": "…", "to": "…"}.filetakes an array of uploaded file ids (see Files below).
The create answers 201 with the full request; GET /api/v1/requests/{id}/ returns the same shape later. stage is the stage the request is waiting on (absent once resolved), fields echo the submitted values, decisions grow with every approval — action is "accept" or "decline":
POST /api/v1/requests/{id}/decisions/ records a decision on behalf of the token's organization admin, who must be an approver of the current stage: action is "approve" or "reject", and a reject requires a comment. The answer is the updated request; deciding twice or without a pending turn is a 400.
Files
POST /api/v1/files/ takes a multipart form with a single file field (up to 20 MB) and answers 201 with the upload. Put its id into a file field when submitting a request — uploads that are never attached expire after a day.
Errors
Every error is JSON with an error message; validation failures add per-field details keyed the way you sent them:
Status codes: 400 for bad input, 401 for a missing or revoked token, 403 when the token owner cannot act on the resource or the subscription is blocked or out of requests, 404 for foreign or unknown ids, 429 over the rate limit, and 5xx when it is on us — retry those.
Webhooks
Add an endpoint URL under Settings → API and every request event of the organization lands there as a signed JSON POST. Right after the endpoint is added we send a ping so you can check the wiring.
Deliveries share one shape: event, request_id, workflow (slug), the request status after the event, who acted, and occurred_at (RFC 3339, UTC). People appear as {id, name} — payloads never carry email addresses.
ping
Sent once, right after the endpoint is added, so you can verify the URL and the signature before real traffic:
request.submitted
A new request entered a workflow. requested_by is the person who opened it:
request.stage_completed
A stage collected the approvals it needed and the request moved on; stage is the stage that just finished, decided_by is the approver whose decision completed it. Not sent for the final stage — that one arrives as request.approved:
request.approved
The final stage accepted the request — it is resolved positively:
request.rejected
An approver declined the request — it is resolved negatively, and comment explains why:
Verifying deliveries
Every delivery carries the event name, a Unix timestamp and an HMAC signature:
Recompute the signature with the webhook secret over the timestamp and the raw body, and compare it to the header:
Compare with a constant-time check and reject stale timestamps to rule out replays.
Retries and auto-disable
Any non-2xx answer or timeout is retried up to 8 times with exponential backoff, a minute at first and capped at 30 minutes. Answer within 10 seconds — do the heavy work after responding.
After 20 failed attempts in a row the endpoint is switched off and the organization admins get an email. Once the receiver is healthy, enable it again under Settings → API.