Backend integration
There are two ways to reach Expeed Relay, and picking the wrong one causes most integration problems.
| Public submissions | Server-to-server notifications | |
|---|---|---|
| Endpoint | POST /api/v1/submissions |
POST /api/v1/notifications |
| Auth | none | x-api-key header |
| Guarded by | Origin allowlist + CAPTCHA | The API key |
| Who sends | A visitor’s browser | Your backend |
| Choose recipients? | No — the form’s configured recipients | Yes, within the form’s allowlist |
| Choose subject/body? | No | Yes |
| Rate limit | 10 / minute / IP | 300 / minute / IP |
Use /submissions when a person filled in a form. Use /notifications when your own system decided something should be sent.
Authentication
Section titled “Authentication”Every request carries the key in a header:
x-api-key: nh_live_xxxxxxxxxxxxxxxxxxxxA missing header returns 401 Missing API key. A key that doesn’t resolve returns 401 Invalid API key. A key belonging to a different site than the siteKey in the body returns 403 API key does not belong to this site.
The simplest call
Section titled “The simplest call”Name a formKey and nothing else, and Expeed Relay notifies exactly who that form is configured to notify — the same recipients and webhooks a real submission would reach.
curl -X POST https://api.relay.expeed.com/api/v1/notifications \ -H "Content-Type: application/json" \ -H "x-api-key: $RELAY_API_KEY" \ -d '{ "siteKey": "marketing-site-a", "formKey": "contact-us", "data": { "name": "Ada Lovelace", "email": "ada@northwind.example", "message": "Please call me back." } }'Response:
{ "success": true, "submissionId": "9f3c21e4-..." }Choosing recipients explicitly
Section titled “Choosing recipients explicitly”Pass recipients to target specific addresses instead of the form’s full list:
curl -X POST https://api.relay.expeed.com/api/v1/notifications \ -H "Content-Type: application/json" \ -H "x-api-key: $RELAY_API_KEY" \ -d '{ "siteKey": "marketing-site-a", "formKey": "contact-us", "recipients": { "email": ["sales@northwind.example"], "whatsapp": ["+15550134"] }, "subject": "New enquiry from Ada", "data": { "name": "Ada Lovelace" } }'Address rules:
email— a single valid address, max 254 characters. Comma-separated lists, display-name forms (Ada <a@x.example>), and anything containing CR/LF are rejected. One field cannot smuggle extra envelope recipients.whatsapp— E.164: a leading+, a country digit 1–9, then 7 to 14 more digits.+15550134is valid;15550134and+0555…are not.- Max 50 addresses per channel per call.
webhookis not a valid channel here. Webhook destinations are configured on the form and fire automatically when you name no recipients at all. Namingwebhookreturns a validation error.
const ENDPOINT = 'https://api.relay.expeed.com/api/v1/notifications';
export async function notify({ formKey, data, subject }) { const response = await fetch(ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': process.env.RELAY_API_KEY, }, body: JSON.stringify({ siteKey: process.env.RELAY_SITE_KEY, formKey, subject, data, }), });
if (!response.ok) { const problem = await response.json().catch(() => ({})); throw new Error( `Expeed Relay ${response.status}: ${problem.message ?? response.statusText}`, ); }
return response.json();}Python
Section titled “Python”import osimport requests
ENDPOINT = "https://api.relay.expeed.com/api/v1/notifications"
def notify(form_key: str, data: dict, subject: str | None = None) -> dict: response = requests.post( ENDPOINT, json={ "siteKey": os.environ["RELAY_SITE_KEY"], "formKey": form_key, "subject": subject, "data": data, }, headers={"x-api-key": os.environ["RELAY_API_KEY"]}, timeout=10, )
if not response.ok: message = response.json().get("message", response.reason) raise RuntimeError(f"Expeed Relay {response.status_code}: {message}")
return response.json()Java (Spring Boot)
Section titled “Java (Spring Boot)”@Servicepublic class Expeed RelayClient {
private static final String ENDPOINT = "https://api.relay.expeed.com/api/v1/notifications";
private final RestClient restClient;
public Expeed RelayClient(RestClient.Builder builder, @Value("${relay.api-key}") String apiKey) { this.restClient = builder .baseUrl(ENDPOINT) .defaultHeader("x-api-key", apiKey) .build(); }
public void notify(String siteKey, String formKey, Map<String, Object> data) { restClient.post() .contentType(MediaType.APPLICATION_JSON) .body(Map.of( "siteKey", siteKey, "formKey", formKey, "data", data)) .retrieve() .onStatus(HttpStatusCode::isError, (request, response) -> { throw new IllegalStateException( "Expeed Relay " + response.getStatusCode()); }) .toBodilessEntity(); }}Make your retries safe
Section titled “Make your retries safe”Send an Idempotency-Key and a retried request will not send twice:
const key = crypto.randomUUID(); // one per logical send, reused across retries
async function notifyWithRetry(payload, attempt = 0) { const response = await fetch(ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': process.env.RELAY_API_KEY, 'Idempotency-Key': key, }, body: JSON.stringify(payload), });
// 409 means another attempt of THIS request is still in flight. if (response.status === 409 && attempt < 3) { await new Promise((r) => setTimeout(r, 500 * 2 ** attempt)); return notifyWithRetry(payload, attempt + 1); }
if (!response.ok) throw new Error(`Expeed Relay ${response.status}`); return response.json();}Generate the key before the first attempt and reuse it for every retry of that operation. A new key per attempt defeats the point. Full rules are on the API reference.
Overriding the message
Section titled “Overriding the message”Three optional fields change what goes out:
| Field | Effect | Limit |
|---|---|---|
subject |
Replaces the template’s subject | 500 characters |
body |
Replaces the template’s HTML body | 100,000 characters |
template |
Accepted but ignored. Nothing reads it — see below | 100 characters |
subject and body are sanitised before sending — script tags and event handlers are stripped, and HTML entities are decoded before stripping rather than after, so encoded markup can’t be reconstituted.
What 201 means
Section titled “What 201 means”201 means the submission was stored and its jobs were queued. Delivery happens afterwards, with retries, and can still fail — a bad provider credential produces a failed job, not a failed API call.
To confirm anything actually arrived, look at Submissions & jobs in the admin, or the monitoring guide.
Related
Section titled “Related”- API reference — every field and every error
- Frontend integration — posting from a browser
- API keys — issuing and rotating keys