API reference
Base URL:
https://api.relay.expeed.com/api/v1All requests and responses are JSON. There are two endpoints.
An OpenAPI document is published at
/api/v1/openapi.json, with
a browsable version at /api/v1/docs.
Generate a client from it rather than hand-writing one — this page is prose, the
document is the contract.
| Endpoint | Auth | Rate limit |
|---|---|---|
POST /submissions |
None — origin allowlist + CAPTCHA | 10 / minute / IP |
POST /notifications |
x-api-key header |
300 / minute / IP |
POST /submissions
Section titled “POST /submissions”Accepts a form submission from a browser. No authentication: what protects it is the site’s origin allowlist and, if configured, CAPTCHA.
Request
Section titled “Request”{ "siteKey": "marketing-site-a", "formKey": "contact-us", "captchaToken": "03AGdBq26...", "data": { "name": "Ada Lovelace", "email": "ada@northwind.example", "message": "Please call me back." }}| Field | Type | Required | Notes |
|---|---|---|---|
siteKey |
string | yes | Max 100 characters. Must match an active site. |
formKey |
string | yes | Max 100 characters. Must match an active form on that site. |
captchaToken |
string | conditional | Required when the site has a CAPTCHA provider configured. Max 4096 characters. |
data |
object | yes | Free-form, within the limits below. |
Limits on data
Section titled “Limits on data”| Limit | Value |
|---|---|
| Fields per object | 100 |
| Nesting depth | 2 levels — data is level 1, data.meta is level 2 |
| Field name length | 128 characters |
| String value length | 10,000 characters |
| Items per array | 100 |
Values may be strings, numbers, booleans, null, plain objects, or arrays. Anything else is rejected.
The Origin check
Section titled “The Origin check”If the site has Allowed origins configured, the request’s Origin header must match one entry exactly — scheme, host and port all compared. A mismatch returns the same 404 as an unknown site or form.
Response
Section titled “Response”201 Created
{ "success": true, "submissionId": "9f3c21e4-..." }201 means stored and queued, not delivered. Delivery runs on a queue with its own retries.
POST /notifications
Section titled “POST /notifications”Sends a notification from your own backend. Requires an API key.
Authentication
Section titled “Authentication”x-api-key: nh_live_xxxxxxxxxxxxxxxxxxxxThe key is bound to one site. If the siteKey in the body is a different site, the request is refused.
Request
Section titled “Request”{ "siteKey": "marketing-site-a", "formKey": "contact-us", "channels": ["email"], "recipients": { "email": ["sales@northwind.example"], "whatsapp": ["+15550134"] }, "subject": "New enquiry from Ada", "body": "<p>Please call back.</p>", "data": { "name": "Ada Lovelace" }}| Field | Type | Required | Notes |
|---|---|---|---|
siteKey |
string | yes | Max 100. Must match the API key’s site. |
formKey |
string | no | Max 100. If given, must resolve to an active form — otherwise 404. |
channels |
string[] | no | email and/or whatsapp. Defaults to the keys of recipients. |
recipients |
object | no | { channel: [address, ...] }. Omit to use the form’s configured recipients and webhooks. |
data |
object | no | Same limits as /submissions. |
template |
string | no | Accepted but ignored — see below. |
subject |
string | no | Max 500. Overrides the template subject. |
body |
string | no | Max 100,000. Overrides the template body. Sanitised before sending. |
Recipient rules
Section titled “Recipient rules”email— one valid address, max 254 characters. Comma-separated lists, display-name forms, and CR/LF are rejected.whatsapp— E.164:+, a country digit 1–9, then 7–14 more digits.- Max 50 addresses per channel per call.
webhookis not accepted. Webhook destinations live on the form and fire when a request names no recipients at all.
Response
Section titled “Response”201 Created
{ "success": true, "submissionId": "9f3c21e4-..." }Idempotency
Section titled “Idempotency”Both endpoints accept an optional Idempotency-Key header.
Idempotency-Key: 3f2504e0-4f89-11d3-9a0c-0305e82c3301Why you want it. A request that times out after the server committed looks exactly like one that never arrived. Your HTTP client cannot tell the difference, and most retry by default — so without a key, a timeout on a slow network sends the notification twice.
With a key, the first response is replayed instead of the work running again.
| Situation | What happens |
|---|---|
| No header | Unchanged behaviour. Every request is independent. |
| Same key, same body | The first response is replayed. Nothing runs twice. |
| Same key, same body, still running | 409 — retry in a moment. |
| Same key, different body | 422. |
| Key shorter than 8 or longer than 255 characters | 400. |
| Same key, 24 hours later | Treated as a new request. |
Use a fresh key per logical operation — a UUID per submission, not per retry. Reuse the same key across the retries of one operation.
Errors
Section titled “Errors”Every error is JSON with a message field:
{ "message": "CAPTCHA verification failed", "statusCode": 400 }| Status | Response | Cause & fix |
|---|---|---|
| 400 | Validation failed | A field is missing, the wrong type, or over a length limit. The message names the field. Check the field tables above. Common causes: data nested more than 2 levels, a value over 10,000 characters, or a reserved key inside data. |
| 400 | CAPTCHA token is required for this site | The site has a CAPTCHA provider configured and no captchaToken was sent. Render the widget and send the token. See CAPTCHA for developers. |
| 400 | CAPTCHA verification failed | The token was invalid, expired, already used, from a mismatched key pair, scored below the v3 threshold, or solved on a hostname not in Allowed Origins. Get a fresh token at submit time — tokens are single-use. If it persists, confirm the key version and that the domain is in both the provider and Allowed Origins. |
| 401 | Missing API key | No x-api-key header on /notifications. Send the header. Note it is x-api-key, not Authorization. |
| 401 | Invalid API key | The key does not match any stored key, or it has been revoked. Issue a new key in the admin under API keys. Revocation takes effect immediately. |
| 403 | API key does not belong to this site | The siteKey in the body is a different site from the one the key was issued for. Use the siteKey the key was issued against, or issue a key for the site you want. |
| 403 | Recipient not configured for this site | An address in recipients is not an active recipient on that form. Add the address to the form in the admin first. The response does not name the address, by design. |
| 404 | Submission target not found or not permitted | On /submissions: unknown site key, unknown form key, the site or form is inactive, OR the Origin is not on the allowlist. All four return the same response so the endpoint cannot be used to discover valid keys. In development this is most often the Origin. Check that too, not just the keys. The real reason is in the server logs. |
| 404 | Form not found: <key>. It may not exist on this site, or it may have been deactivated. | On /notifications: the formKey does not resolve to an active form. Check the form exists and is active. This used to return 201 and silently notify nobody. |
| 409 | A request with this Idempotency-Key is already in progress. Retry shortly. | Another request with the same Idempotency-Key is currently being processed. Wait a moment and retry with the same key. You will get the first request's response. |
| 422 | Idempotency-Key has already been used with a different request body | The key was reused for a different payload. Use a fresh key per logical operation. Reuse a key only across retries of the SAME request. |
| 400 | Idempotency-Key must be between 8 and 255 characters | The header was present but too short or too long. Use a UUID. |
| 429 | ThrottlerException: Too Many Requests | Over the rate limit: 10/minute/IP on /submissions, 300/minute/IP on /notifications. Back off and retry. Note the submissions limit is per IP, so a shared corporate NAT can hit it with genuine traffic. |
| 503 | CAPTCHA is misconfigured for this site | The site names a CAPTCHA provider but no secret key is stored. An admin fix, not yours. Expeed Relay refuses rather than skipping verification, because skipping would let every bot through silently. |
| 503 | CAPTCHA verification temporarily unavailable. Please try again. | Expeed Relay could not reach Google or Cloudflare to check the token. Usually transient. Retry with a fresh token. |
What a 201 does not tell you
Section titled “What a 201 does not tell you”Both endpoints return 201 once the submission is stored and its jobs are queued. Delivery happens afterwards on a queue, and can still fail — an expired provider credential produces a failed job, not a failed API call.
Failed jobs retry with exponential backoff. A job that exhausts its attempts is marked dead and stays visible for manual retry. To confirm anything arrived, use Submissions & jobs in the admin — see monitoring delivery.
Related
Section titled “Related”- Frontend integration — posting from a browser
- Backend integration — server-to-server with an API key
- CAPTCHA for developers — widgets, tokens, and CSP