Skip to content

API reference

Base URL:

https://api.relay.expeed.com/api/v1

All 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

Accepts a form submission from a browser. No authentication: what protects it is the site’s origin allowlist and, if configured, CAPTCHA.

{
"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.
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.

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.

201 Created

{ "success": true, "submissionId": "9f3c21e4-..." }

201 means stored and queued, not delivered. Delivery runs on a queue with its own retries.


Sends a notification from your own backend. Requires an API key.

x-api-key: nh_live_xxxxxxxxxxxxxxxxxxxx

The key is bound to one site. If the siteKey in the body is a different site, the request is refused.

{
"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.
  • 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.
  • webhook is not accepted. Webhook destinations live on the form and fire when a request names no recipients at all.

201 Created

{ "success": true, "submissionId": "9f3c21e4-..." }

Both endpoints accept an optional Idempotency-Key header.

Idempotency-Key: 3f2504e0-4f89-11d3-9a0c-0305e82c3301

Why 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.


Every error is JSON with a message field:

{ "message": "CAPTCHA verification failed", "statusCode": 400 }
StatusResponseCause & fix
400Validation 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.

400CAPTCHA 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.

400CAPTCHA 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.

401Missing API key

No x-api-key header on /notifications.

Send the header. Note it is x-api-key, not Authorization.

401Invalid 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.

403API 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.

403Recipient 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.

404Submission 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.

404Form 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.

409A 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.

422Idempotency-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.

400Idempotency-Key must be between 8 and 255 characters

The header was present but too short or too long.

Use a UUID.

429ThrottlerException: 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.

503CAPTCHA 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.

503CAPTCHA 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.


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.