Skip to content

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.

Every request carries the key in a header:

x-api-key: nh_live_xxxxxxxxxxxxxxxxxxxx

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

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.

Terminal window
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-..." }

Pass recipients to target specific addresses instead of the form’s full list:

Terminal window
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. +15550134 is valid; 15550134 and +0555… are not.
  • Max 50 addresses per channel per call.
  • webhook is not a valid channel here. Webhook destinations are configured on the form and fire automatically when you name no recipients at all. Naming webhook returns 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();
}
import os
import 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()
@Service
public 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();
}
}

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.

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.

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.