Node.js SDK (relay-surfih-sdk)
Signed client for Relay /api/v1, plus a verifier for webhook deliveries. ESM, Node 20+.
For HTTP details, signing strings, and webhook headers, see the API documentation. For product patterns, see usage examples.
Install
npm install relay-surfih-sdk
Requires Node.js 20 or newer.
Set credentials from the dashboard Clients page:
export RELAY_API_KEY=ak_live_...
export RELAY_API_SECRET=cs_live_...
Quick start (HMAC)
import { createRelay } from "relay-surfih-sdk";
const relay = createRelay({
apiKey: process.env.RELAY_API_KEY!,
secret: process.env.RELAY_API_SECRET!,
});
const created = await relay.queues.create(
{ name: "orders", webhook_url: "https://app.example.com/hooks/relay" },
{ idempotencyKey: "queue-orders" },
);
const accepted = await relay.events.submit(
created.queue.id,
{ event_type: "order.created", event: { id: "ord_1" } },
{ idempotencyKey: "order-ord_1" },
);
baseUrl defaults to https://relay.surfih.com. Pass it only for local or staging origins.
Idempotency keys matter
idempotencyKey is required on create queue, submit event, and post result. Relay treats it as the identity of that write:
| What you send | What Relay does |
|---|
| New key | Creates a new event (or queue / result) and returns it |
| Same key + same body | Does not start a new run. Returns the existing accepted request (same event_id) |
| Same key + different body | 409 conflict — key reused with a different payload |
Repeating a submit with "order-ord_1" will not enqueue another delivery. You get the latest stored request for that key back. Use a new key whenever you intentionally want a new run (for example include a unique business id, or a new UUID). For result callbacks, use a different key from the submit key (e.g. result_<event_id>).
Full contract: Idempotency-Key.
API surface
| Method | HTTP | Idempotency-Key |
|---|
relay.queues.create(body, { idempotencyKey }) | POST /api/v1/queues | required |
relay.events.submit(queueId, body, { idempotencyKey }) | POST /api/v1/queues/{queueId}/events | required |
relay.events.get(eventId) | GET /api/v1/events/{eventId} | — |
relay.events.get(eventId, { attempts: true }) | GET …?attempts=1 | — |
relay.events.result(eventId, body, { idempotencyKey }) | POST /api/v1/events/{eventId}/result | required |
relay.events.checkpoints(eventId, checkpoints) | POST /api/v1/events/{eventId}/checkpoints | — |
relay.schedules.list(queueId) | GET /api/v1/queues/{queueId}/schedules | — |
relay.schedules.create(queueId, body) | POST /api/v1/queues/{queueId}/schedules | — |
relay.schedules.delete(scheduleId) | DELETE /api/v1/schedules/{scheduleId} | — |
Events
await relay.events.submit(
queueId,
{ event_type: "order.created", event: { id: "ord_1" }, delay_seconds: 60 },
{ idempotencyKey: "order-ord_1" },
);
const event = await relay.events.get(accepted.event_id);
const withAttempts = await relay.events.get(accepted.event_id, { attempts: true });
await relay.events.result(
accepted.event_id,
{ status: "completed", result: { provider_id: "msg_1" } },
{ idempotencyKey: "result-ord_1" },
);
await relay.events.checkpoints(accepted.event_id, [
{ tag: "received", at: new Date().toISOString() },
{ tag: "done" },
]);
Use at most one of deliver_at / delay_seconds; omit both for ASAP delivery. send_message events must carry only { notification_id } in event.
Schedules
const { schedules, limit } = await relay.schedules.list(queueId);
const { schedule } = await relay.schedules.create(queueId, {
name: "daily-digest",
cron: "0 9 * * *",
timezone: "America/Sao_Paulo",
event_type: "digest.run",
event: { kind: "daily" },
});
await relay.schedules.delete(schedule.id);
Ed25519 or KMS
Pass a PKCS#8 Ed25519 privateKeyPem, or a sign hook that returns the full header value (sha256=… or ed25519=…):
const relay = createRelay({
apiKey,
sign: async (bytes) => {
const signature = await kmsSignEd25519(bytes); // 64 raw bytes
return "ed25519=" + Buffer.from(signature).toString("base64");
},
});
Register the matching SPKI public key on the queue (request_public_key_pem) before the first ed25519= call.
Verify webhooks
import { verifyWebhook } from "relay-surfih-sdk";
const delivery = verifyWebhook({
apiKey,
secret,
rawBody, // exact bytes, before JSON.parse
headers: req.headers,
deliveryPublicKeyPem, // set when webhook_auth_mode is "ed25519"
});
delivery.eventId;
delivery.attempt;
delivery.idempotencyKey;
Delivery is at least once. Deduplicate on the signed pair (event_id, attempt) and on Idempotency-Key before side effects. Acknowledge with HTTP 2xx, then report the business outcome with relay.events.result(...).
Errors
Failed /api/v1 calls throw RelayError (status, message, optional code, requestId, retryAfter, issues, quota fields). Webhook verification failures throw WebhookVerificationError.
Relay usage examples
Relay is useful whenever an application should accept a request quickly and run the resulting work asynchronously. The application submits a small JSON event, Relay persists it, and a worker delivers it to the configured webhook. The webhook acknowledges receipt with HTTP 2xx, processes the work, and later reports completed, error, or custom to Relay.
Common use cases
| Use case | Event example | What the webhook does | Why use Relay |
|---|
| Send a transactional email | email.send | Renders a template and calls an email provider | The user-facing request can finish without waiting for the provider |
| Send a push notification | push.send | Sends to APNs, FCM, or another push provider | Absorbs traffic spikes and keeps provider latency outside the main request path |
| Send an SMS or WhatsApp message | message.send | Calls the messaging provider | Keeps messaging work outside the main request path and makes its status observable |
| Process an uploaded image | image.process | Downloads an object, resizes or optimizes it, and uploads the outputs | Moves CPU-heavy work out of the upload request |
| Transcode uploaded audio or video | media.transcode | Downloads the source, creates output formats, and stores them | Runs long or resource-intensive work asynchronously |
| Scan an upload | file.scan | Downloads a quarantined object and runs malware or content checks | The file can remain unavailable until a verified result arrives |
| Extract text from a document | document.ocr | Runs OCR or parsing and stores the extracted data | Separates slow document processing from the API request |
| Generate a PDF | document.generate | Renders an invoice, receipt, ticket, or report and stores it | Avoids blocking the user while rendering and uploading the file |
| Generate thumbnails or previews | asset.preview | Creates thumbnails, waveform images, or document previews | One upload can trigger independent derived-asset jobs |
| Run an AI task | ai.summarize | Calls a model, validates the output, and stores the result | Handles variable model latency and reports a separate business result |
| Process an order | order.process | Reserves stock, invokes fulfillment, or coordinates downstream actions | Provides durable delivery and an auditable event status |
| Capture or reconcile a payment | payment.reconcile | Queries a payment provider and updates internal records | Retries transport failures without holding the checkout response open |
| Synchronize a CRM or ERP | customer.sync | Upserts the record in a third-party system | Isolates the application from third-party rate limits and downtime |
| Update a search index | search.index | Reads the latest entity state and updates the search provider | Makes indexing eventually consistent without delaying writes |
| Export data | export.generate | Builds a CSV or archive, stores it, and returns a download reference | Supports long-running exports while exposing their final status |
| Import a data file | import.process | Reads a stored file, validates rows, and persists valid records | Allows progress or validation failures to be reported asynchronously |
| Fan out an internal domain event | customer.created | A webhook routes the event to analytics, onboarding, or other services | Decouples the originating request from downstream integrations |
| Deliver a partner webhook | partner.notify | Transforms the payload and calls a partner endpoint | Adds durable delivery, retry history, and idempotency around the integration |
| Record analytics or audit data | activity.record | Enriches and writes the activity to its destination | Keeps non-critical writes away from the main response path |
| Invalidate or warm a cache | cache.refresh | Rebuilds cached views or preloads frequently used data | Performs expensive refreshes after the source change is committed |
| Send a delayed reminder | reminder.send with delay_seconds or deliver_at | Sends the reminder when Relay delivers the event | Avoids keeping application timers alive |
| Run recurring housekeeping | A schedule emitting maintenance.run | Deletes expired data, reconciles records, or builds a digest | Uses cron schedules with a named IANA timezone |
| Produce a daily or weekly digest | A schedule emitting digest.generate | Queries the period, creates the digest, and sends or stores it | Turns recurring work into ordinary observable Relay events |
Pattern 1: respond to the user before sending an email
The application should submit the event after it has committed the business operation. It can then return its normal response without waiting for the email provider.
User -> Application: create order
Application -> Database: commit order
Application -> Relay: submit email.send
Relay -> Application: 202 Accepted + event_id
Application -> User: order created
Relay -> Email webhook: deliver email.send
Email webhook -> Email provider: send message
Email webhook -> Relay: report completed or error
Example event body:
{
"event_type": "email.send",
"event": {
"message_id": "msg_01J...",
"template": "order-confirmation",
"recipient": "customer@example.com",
"variables": {
"order_id": "ord_123"
}
}
}
Use a stable business identifier such as email:order-confirmation:ord_123 as the event Idempotency-Key. If the application retries the submission with the same payload, Relay returns the existing event instead of enqueueing another email.
For stronger consistency between the database commit and event submission, use a transactional outbox in the application database and have an outbox publisher submit pending rows to Relay.
Pattern 2: send push notifications
Submit one logical notification event and let the webhook own provider-specific behavior. The payload can contain a notification record ID rather than every device token, allowing the webhook to read the latest recipients and preferences before sending.
{
"event_type": "push.send",
"event": {
"notification_id": "ntf_123",
"audience_id": "user_456"
}
}
The webhook can report a summary as the result:
{
"status": "completed",
"result": {
"sent": 2,
"invalid_tokens": 1
}
}
If one event can send to many devices, the webhook must make its own provider calls idempotent. Relay guarantees at-least-once delivery, so the same delivery may arrive again after an ambiguous network failure.
Pattern 3: process an uploaded image
Upload the binary directly to object storage using a presigned URL. After the upload completes, submit only the storage reference and processing instructions to Relay. Do not put image bytes or large base64 values in the event: Relay accepts JSON request bodies and the configured default maximum body size is 256 KiB.
Client -> Object storage: upload image
Client -> Application: confirm upload
Application -> Relay: submit image.process with object key
Application -> Client: 202 Processing
Relay -> Image webhook: deliver image.process
Image webhook -> Object storage: download source
Image webhook: validate, resize, optimize
Image webhook -> Object storage: upload variants
Image webhook -> Relay: report output references
Client -> Application: poll application status or receive an app-level update
Example event body:
{
"event_type": "image.process",
"event": {
"asset_id": "asset_123",
"source": {
"bucket": "uploads",
"key": "incoming/asset_123/original.jpg",
"version": "3Lg..."
},
"operations": [
{ "name": "thumbnail", "width": 320, "height": 320 },
{ "name": "web", "width": 1600, "format": "webp" }
]
}
}
Example result callback:
{
"status": "completed",
"result": {
"asset_id": "asset_123",
"outputs": [
{ "name": "thumbnail", "key": "processed/asset_123/thumbnail.webp" },
{ "name": "web", "key": "processed/asset_123/web.webp" }
]
}
}
Prefer immutable object keys or include an object version/checksum. This prevents a retry from processing different bytes after the original object has been replaced.
Delayed and recurring examples
Use delay_seconds for a delay relative to submission, or deliver_at for a specific RFC 3339 instant. They are mutually exclusive and can schedule an event up to 90 days ahead.
Examples include:
- Send an abandoned-cart reminder 30 minutes later.
- Notify an attendee one day before an event.
- Release a scheduled article at a specific time.
- Recheck a payment after a settlement window.
- Remove a temporary resource after its retention period.
Use schedules for recurring work, such as a daily digest, hourly reconciliation, or weekly cleanup. A schedule uses a five-field cron expression and an IANA timezone; every tick creates a normal event with the same delivery and result lifecycle as an event submitted directly.
Integration rules
Every implementation should account for these platform semantics:
- A webhook HTTP
2xx means only that delivery was accepted. Report the business outcome separately with POST /api/v1/events/{event_id}/result.
- Delivery is at least once. Verify the delivery signature and deduplicate using the incoming
Idempotency-Key before performing side effects.
- Use a different stable idempotency key, such as
result_<event_id>, when posting the result callback.
- Keep payloads small. Store large files and large generated outputs elsewhere and pass stable references in events and results.
- Treat event payloads as instructions or references, not as the authoritative mutable record. Include stable entity IDs and let the webhook read current state when that is the desired behavior.
- Design external side effects to be idempotent. Prefer provider idempotency keys, unique business-operation records, or a local deduplication table.
- Use
custom for a domain-specific outcome such as awaiting_approval, and error for a failed business outcome. A stored result is final for that execution unless the event is rerun, so do not use result callbacks as a stream of progress updates.
- Save the returned
event_id when the application needs to query delivery and result status later.
When Relay is not the right tool
Relay is not a replacement for object storage, a streaming data platform, or an interactive request/response API. Avoid using it to transport large binaries, stream high-volume telemetry, or perform work whose result must be returned synchronously in the same user request. It is best suited to durable JSON job and event delivery to HTTP workers, with explicit asynchronous results.
For the Node.js client, see Node.js SDK (relay-surfih-sdk). For request formats, signing, webhook validation, and result callbacks, see API documentation. For the complete deduplication contract, see Idempotency-Key.
Events API (/api/v1)
Client guide for the public events API: every operation, required headers, how to build signatures and bodies, webhook delivery, and how to report completed / error / custom back to the relay.
Related docs: sdk.md (Node.js client), architecture.md (design), idempotency.md (Idempotency-Key chain), AGENT.md (agent integration playbook).
Base path: /api/v1. All timestamps are UTC. Bodies are JSON (Content-Type: application/json) unless the method has no body (GET, DELETE).
Node.js SDK
Prefer the published client when integrating from Node.js 20+:
npm install relay-surfih-sdk
See Node.js SDK for install, HMAC/Ed25519 signing, queues/events/schedules, and webhook verification. npm: relay-surfih-sdk. Source: surfih/relay-sdk-nodejs.
Integrate with a coding agent
To wire Relay into an existing app, give your favorite coding agent AGENT.md:
- Copy
docs/AGENT.md into the target project (or keep a path/URL your agent can read).
- Point the agent at it — e.g.
@docs/AGENT.md in Cursor, Claude Code, Copilot, Windsurf, or any other agent that accepts a context file.
- Ask something like: “Follow AGENT.md and integrate Relay into this codebase.”
AGENT.md is a self-contained phased playbook (credentials → request signer → queue → webhook → events → result callback). Agents given only that file should not need examples/ or other docs. Use this document when you want the full HTTP reference; use examples/send-message/ only when you have this repository checked out and want runnable scripts. Use the Node.js SDK when you want a maintained client instead of hand-rolled signing.
1. Credentials
| Value | Prefix | Role |
|---|
| API key | ak_live_… | Sent as X-API-Key. Identifies the tenant. Alone it authorises nothing. |
| API secret | cs_live_… | HMAC key for sha256= signatures (client → relay and relay → webhook). Never sent on the wire. |
| Request key pair (optional) | Ed25519 PEM | Private key stays with you (or in a KMS); public key is stored on the queue as request_public_key_pem. Used for ed25519= on /api/v1 requests. |
Credentials are created with the tenant (signup / dashboard Clients page). Each tenant has one ak_live_ / cs_live_ pair.
2. Headers (client → relay)
Every /api/v1 call needs these three:
| Header | Required | Notes |
|---|
X-API-Key | yes | Your ak_live_… |
X-Timestamp | yes | Unix seconds when you signed. Accepted within ±300 s of the server clock |
X-Payload-Signature | yes | sha256=<hex> or ed25519=<base64> — pick one of the two methods below |
Idempotency-Key | writes only | Required on create queue, submit event, and post result. Max 200 characters. Not used on schedules or GET / DELETE |
Content-Type | when body | application/json |
Header names are case-insensitive.
2.1 Signing methods
Every /api/v1 request must carry X-Payload-Signature. The header prefix selects the method. Both methods sign the same message bytes; only the algorithm and the key material differ.
Shared signing string
ak_live_ + "." + X-Timestamp + "." + METHOD + "." + path_and_query + "." + raw_body
METHOD is uppercase (POST, GET, DELETE).
path_and_query is the exact request path, including query string when present
(example: /api/v1/events/evt_1?attempts=1).
raw_body is the exact bytes you send. Use an empty string for GET and DELETE.
- Sign those bytes as they are. Do not hash them first (Ed25519 already hashes internally).
Choosing a method
| Method A — HMAC-SHA256 | Method B — Ed25519 |
|---|
| Header value | sha256=<hex> | ed25519=<base64> |
| Key you hold | API secret cs_live_… (symmetric) | Ed25519 private key (asymmetric) |
| What the relay stores | Encrypted cs_live_ (must be recoverable to verify HMAC) | Only the public key PEM on the tenant / queue |
| Works on | Every /api/v1 endpoint | Every /api/v1 endpoint once a public key is configured |
| Best when | Simple scripts, local tools, you already have cs_live_ in the process | Production services that must not hold a long-lived shared secret — especially AWS KMS (or another HSM / cloud KMS) |
| Failure mode | Invalid secret → 401 | Missing or wrong public key / bad signature → 401 |
You can mix methods across calls. The relay looks only at the prefix of X-Payload-Signature for that request. Webhook deliveries from the relay always use HMAC (sha256= over api_key.body); that is separate from how you sign calls into /api/v1.
2.2 Method A — HMAC-SHA256 (sha256=)
Use the tenant API secret as the HMAC key. No public key setup is required. Suitable for dashboards, CLIs, and environments where cs_live_ can live in a secret store your process can read.
import { createHmac } from "node:crypto";
function signHmac({ apiKey, secret, timestamp, method, path, body }) {
const msg = `${apiKey}.${timestamp}.${method.toUpperCase()}.${path}.${body}`;
return "sha256=" + createHmac("sha256", secret).update(msg).digest("hex");
}
Example header:
X-Payload-Signature: sha256=a1b2c3…
examples/send-message/02-create-queue.mjs signs this way.
2.3 Method B — Ed25519 (ed25519=)
Sign with the Ed25519 private key whose public PEM is registered on the resource:
- Creating a queue (
POST /api/v1/queues): the tenant default request_public_key_pem, or the PEM you send in the body (copied onto the new queue).
- Events, results, schedules: the public key stored on that queue (
queues.request_public_key_pem).
If the queue (or tenant, for create-queue) has no public key, ed25519= returns 401.
Why Ed25519 (and AWS KMS): the relay only needs the public key to verify. The private key never leaves your side — so you can keep it in AWS KMS (ECC_NIST is not used here; use an Ed25519 key in KMS / CloudHSM, or export a public key from a key you manage and call Sign with Ed25519 / raw message). Your app builds the signing string locally, asks KMS to sign those bytes, and puts ed25519=<base64> on the request. The cs_live_ secret is not required in that process for /api/v1 auth (you still need ak_live_ in X-API-Key, and cs_live_ only if you also verify webhook deliveries yourself with HMAC).
Local private key (PEM)
import { createPrivateKey, sign } from "node:crypto";
function signEd25519({ apiKey, privatePem, timestamp, method, path, body }) {
const msg = Buffer.from(
`${apiKey}.${timestamp}.${method.toUpperCase()}.${path}.${body}`,
);
// Node signs the message bytes directly (Ed25519). Encode the 64-byte signature as standard base64.
return "ed25519=" + sign(null, msg, createPrivateKey(privatePem)).toString("base64");
}
AWS KMS sketch
- Create (or import) an Ed25519 signing key in KMS; grant your runtime
kms:Sign and kms:GetPublicKey.
- Export the public key (SPKI / PEM) and set it as
request_public_key_pem on the queue (create-queue body or dashboard).
- For each API call, build the same signing string as above, then:
import { KMSClient, SignCommand } from "@aws-sdk/client-kms";
const kms = new KMSClient({ region: process.env.AWS_REGION });
async function signEd25519WithKms({ keyId, apiKey, timestamp, method, path, body }) {
const message = Buffer.from(
`${apiKey}.${timestamp}.${method.toUpperCase()}.${path}.${body}`,
);
const out = await kms.send(
new SignCommand({
KeyId: keyId, // key id or alias ARN
Message: message, // raw signing string — do not pre-hash
MessageType: "RAW",
SigningAlgorithm: "ED25519",
}),
);
return "ed25519=" + Buffer.from(out.Signature).toString("base64");
}
Exact SDK field names follow the AWS API for Ed25519 keys; the important contract for Relay is: sign the raw signing string, then send ed25519= + standard base64 of the 64-byte signature. Register the matching public key PEM on the queue before the first ed25519= call.
examples/send-message/03-send-event.mjs signs with a local Ed25519 PEM (same header format as KMS).
Example header:
X-Payload-Signature: ed25519=AQID…
2.4 Building the request headers
Pick one signer, then attach the shared headers. The timestamp in the header must be the same value that went into the signing string.
function v1Headers({ apiKey, method, path, body = "", idempotencyKey, sign }) {
const timestamp = String(Math.floor(Date.now() / 1000));
const signature = sign({ apiKey, timestamp, method, path, body });
const headers = {
"X-API-Key": apiKey,
"X-Timestamp": timestamp,
"X-Payload-Signature": signature,
"Content-Type": "application/json",
};
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
return headers;
}
// Method A — HMAC with cs_live_
const headersHmac = v1Headers({
apiKey, method: "POST", path, body, idempotencyKey: "order-1",
sign: (p) => signHmac({ ...p, secret }),
});
// Method B — local Ed25519 PEM (or pass signEd25519WithKms for AWS KMS)
const headersEd = v1Headers({
apiKey, method: "POST", path, body, idempotencyKey: "order-1",
sign: (p) => signEd25519({ ...p, privatePem }),
});
A clock skew larger than 300 seconds between X-Timestamp and the server yields 401.
3. Operations
Summary
| Method | Path | Idempotency-Key | Purpose |
|---|
POST | /api/v1/queues | required | Create (or return) a queue |
POST | /api/v1/queues/{queue_id}/events | required | Submit an event |
GET | /api/v1/events/{event_id} | — | Fetch event status (?attempts=1 includes attempts) |
POST | /api/v1/events/{event_id}/result | required | Report business outcome |
POST | /api/v1/events/{event_id}/checkpoints | — | Report webhook timings (tagged checkpoints) |
POST | /api/v1/queues/{queue_id}/schedules | — | Create a recurring schedule |
GET | /api/v1/queues/{queue_id}/schedules | — | List schedules for a queue |
DELETE | /api/v1/schedules/{schedule_id} | — | Delete a schedule |
Dashboard admin routes (/api/admin/*) use the session cookie, not these headers, and are out of scope here.
3.1 Create queue
POST /api/v1/queues
Headers: X-API-Key, X-Timestamp, X-Payload-Signature, Idempotency-Key
Body:
| Field | Required | Notes |
|---|
name | yes | 1–120 characters |
webhook_url | yes | Absolute URL of your receiver |
request_public_key_pem | no | Ed25519 public PEM for verifying later ed25519= requests on this queue |
webhook_auth_mode | no | none (default) or ed25519 (extra delivery signature) |
POST /api/v1/queues
X-API-Key: ak_live_…
X-Timestamp: 1730112000
Idempotency-Key: queue-messages
X-Payload-Signature: sha256=<hex>
Content-Type: application/json
{
"name": "messages",
"webhook_url": "https://example.com/hooks/relay",
"request_public_key_pem": "-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----\n",
"webhook_auth_mode": "none"
}
Responses:
201 — queue created: { "created": true, "queue": { … } }
200 — same idempotency key + same body, or a queue with that name already exists: { "created": false, "queue": { … } }
409 — same idempotency key, different body
queue.id is the queue_id used in later calls.
3.2 Submit event (send)
POST /api/v1/queues/{queue_id}/events
Headers: X-API-Key, X-Timestamp, X-Payload-Signature, Idempotency-Key
Body:
| Field | Required | Notes |
|---|
event_type | yes | Free string (1–200). Special case: send_message requires event to be only { "notification_id": "…" } |
event | no | Arbitrary JSON payload delivered to the webhook |
delay_seconds | no | Integer ≥ 0; delay from now. Max 90 days. Mutually exclusive with deliver_at |
deliver_at | no | RFC 3339 instant. Past values deliver immediately. Max 90 days ahead |
POST /api/v1/queues/que_…/events
X-API-Key: ak_live_…
X-Timestamp: 1730112000
Idempotency-Key: order-456
X-Payload-Signature: sha256=<hex>
Content-Type: application/json
{
"event_type": "order.created",
"event": { "order_id": "ord_456", "amount": 49.9 }
}
Delayed example:
{
"event_type": "order.created",
"event": { "order_id": "ord_456" },
"delay_seconds": 3600
}
Response 202 Accepted:
{
"event_id": "evt_…",
"server_status": "idle",
"client_status": null,
"deliver_at": null
}
| Replay case | Result |
|---|
| Same key, same body | 202 with the existing event_id |
| Same key, different body | 409 with existing_event_id |
| Queue paused | 409 |
Save event_id for status polls and for the result callback.
Node example:
const path = `/api/v1/queues/${queueId}/events`;
const body = JSON.stringify({
event_type: "order.created",
event: { order_id: "ord_456", amount: 49.9 },
});
const res = await fetch(`${relay}${path}`, {
method: "POST",
headers: v1Headers({
apiKey, method: "POST", path, body, idempotencyKey: "order-456",
sign: (p) => signHmac({ ...p, secret }), // or signEd25519 / KMS
}),
body,
});
const { event_id } = await res.json();
3.3 Get event (receive status)
GET /api/v1/events/{event_id}
Optional query: ?attempts=1 to include delivery attempts and webhook checkpoints.
Headers: X-API-Key, X-Timestamp, X-Payload-Signature
Body: empty (sign with raw_body = "")
GET /api/v1/events/evt_…?attempts=1
X-API-Key: ak_live_…
X-Timestamp: 1730112000
X-Payload-Signature: sha256=<hex>
Response 200 (shape):
{
"event_id": "evt_…",
"queue_id": "que_…",
"event_type": "order.created",
"event": { "order_id": "ord_456", "amount": 49.9 },
"server_status": "sent",
"client_status": "completed",
"attempt_count": 1,
"delivery_exhausted": false,
"server_result": { "http_status": 202, "response_summary": "…" },
"server_error": null,
"client_result": { "reference": "ord_456" },
"client_error": null,
"result_deadline_at": "…",
"result_timed_out_at": null,
"created_at": "…",
"updated_at": "…",
"sent_at": "…",
"finished_at": "…",
"deliver_at": null,
"attempts": [ /* only with ?attempts=1 */ ],
"checkpoints": [ /* only with ?attempts=1, see §3.5 */ ]
}
Useful server_status values: idle → reserved / processing → sent (webhook accepted) or delivery_error. After sent, wait for client_status (completed | error | custom) or a result timeout.
3.4 Post result (callback)
POST /api/v1/events/{event_id}/result
Call this from your webhook worker after you accepted the delivery (HTTP 2xx). The event must be in server_status=sent (the relay waits up to ~5 s if delivery is still finishing).
Headers: X-API-Key, X-Timestamp, X-Payload-Signature, Idempotency-Key
Use a new idempotency key for the result (not the delivery key). A stable choice: result_<event_id>. Details in idempotency.md.
Success — completed
{
"status": "completed",
"result": { "reference": "ord_456" }
}
result is optional JSON (any object).
Failure — error
{
"status": "error",
"error": {
"code": "PAYMENT_REJECTED",
"message": "Payment was rejected"
}
}
error.code and error.message are required non-empty strings. Extra keys under error are dropped.
Custom client status — custom
{
"status": "custom",
"result": { "code": "awaiting_approval", "ticket": "t_9" }
}
Use when the outcome is neither a plain success nor a hard error. Same shape as completed for the optional result field.
Response 200: event view plus "replay": true|false.
| Case | Result |
|---|
First result while sent and no client result yet | 200, stored |
| Same key + same body again | 200, "replay": true |
| Same key, different body / different key after a result | 409 |
Event not sent (and not still in flight) | 409 |
Node example:
async function reportResult(eventId, status, payload) {
const path = `/api/v1/events/${eventId}/result`;
const body = JSON.stringify(
status === "error"
? { status: "error", error: payload }
: { status, result: payload },
);
const res = await fetch(`${relay}${path}`, {
method: "POST",
headers: v1Headers({
apiKey, method: "POST", path, body,
idempotencyKey: `result_${eventId}`,
sign: (p) => signHmac({ ...p, secret }), // or signEd25519 / KMS
}),
body,
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
await reportResult(eventId, "completed", { reference: "ord_456" });
await reportResult(eventId, "error", { code: "PAYMENT_REJECTED", message: "…" });
await reportResult(eventId, "custom", { code: "awaiting_approval" });
3.5 Checkpoints (webhook timings)
POST /api/v1/events/{event_id}/checkpoints
Lets your webhook report what happens between the delivery and the result, so the dashboard timeline shows your own steps next to the relay's (queue wait, delivery, sent, result). A checkpoint is a tag and a timestamp, nothing else.
Headers: X-API-Key, X-Timestamp, X-Payload-Signature. No Idempotency-Key is needed: a checkpoint with the same tag and at as a stored one is ignored, so retries are safe.
{
"checkpoints": [
{ "tag": "received", "at": "2026-10-29T10:00:00.120Z" },
{ "tag": "db_write", "at": "2026-10-29T10:00:00.348Z" },
{ "tag": "done" }
]
}
| Field | Rule |
|---|
tag | 1–64 characters of a-z, 0-9, _ . : - |
at | Optional. RFC 3339 with an explicit offset. Omitted means the time the relay received it. |
| batch | 1–20 checkpoints per request, at most 100 per event |
Time zone: send UTC. Use Z (new Date().toISOString() in JavaScript, datetime.now(timezone.utc).isoformat() in Python) with millisecond precision. Other offsets such as +01:00 are accepted and converted, because they name the same instant, but a timestamp without an offset is rejected: it does not name an instant. All timestamps are stored and returned in UTC.
Clock skew. Time zones are not the problem; clocks that disagree are. The relay records its own received_at for each checkpoint, so you can compare the two. at must fall between one minute before the event was created and one minute after the relay's clock. Otherwise the request fails with 400. Keep your hosts NTP-synced. The dashboard flags a checkpoint whose at is later than received_at or earlier than the start of its attempt.
When: from the first delivery attempt (including while the webhook request is still in flight) until 10 minutes after the event closes (client result, result timeout, delivery_error or cancelled). Each checkpoint is tied to the latest attempt that had started by its at.
Response 200:
{
"accepted": 3,
"checkpoints": [
{ "tag": "received", "at": "2026-10-29T10:00:00.120Z", "received_at": "2026-10-29T10:00:00.402Z", "attempt_number": 1 }
]
}
accepted counts newly stored checkpoints. checkpoints lists all of the event's, ordered by at.
| Case | Result |
|---|
| Valid batch inside the window | 200 |
| Same checkpoints again | 200, "accepted": 0 |
Bad tag, missing offset, at out of range, empty or oversized batch | 400 |
| Not delivered yet, or closed more than 10 minutes ago | 409 |
| Would exceed 100 checkpoints for the event | 409 |
3.6 Schedules
Recurring events. Each tick creates a normal queue event with idempotency key schedule:<id>:<tick>.
Create — POST /api/v1/queues/{queue_id}/schedules
{
"name": "hourly-digest",
"cron": "0 * * * *",
"timezone": "UTC",
"event_type": "digest.run",
"event": { "kind": "hourly" }
}
| Field | Required | Notes |
|---|
name | yes | Unique per client |
cron | yes | 5-field cron, or @hourly / @daily / … |
timezone | no | IANA name, default UTC |
event_type | yes | Same rules as submit event |
event | yes | Payload for each tick |
Response 201: { "schedule": { … } }. The current plan controls the allowance: 2 schedules
on the free plan and 100 on paid plans.
List — GET /api/v1/queues/{queue_id}/schedules → { "schedules": [ … ], "limit": 2 }
(limit is the current plan allowance across all queues.)
Delete — DELETE /api/v1/schedules/{schedule_id} → { "deleted": true }
Sign with empty body.
4. Building a webhook
The relay POSTs to the queue’s webhook_url. Your HTTP response only means delivery accepted. Business outcome goes through §3.4 afterwards.
4.1 Delivery request (relay → you)
Headers always present:
| Header | Meaning |
|---|
X-API-Key | Your ak_live_… |
X-Payload-Signature | sha256=<hex> HMAC of ak_live_ + "." + raw_body with cs_live_ |
Idempotency-Key | Key from event creation (retries keep it; reruns append :rerun:<n>) |
X-Event-Id | Stable event_id |
X-Event-Attempt | Attempt number for this execution |
X-Queue-Id | Queue id |
Content-Type | application/json |
Body:
{
"event_id": "evt_…",
"queue_id": "que_…",
"event_type": "order.created",
"event": { "order_id": "ord_456", "amount": 49.9 },
"attempt": 1,
"created_at": "2026-10-28T12:00:00Z"
}
Default accepted statuses: 200, 202, 204. Anything else is retried (per queue rules).
4.2 Optional Ed25519 delivery signature
If the queue was created with webhook_auth_mode: "ed25519", each attempt also includes:
| Header | Meaning |
|---|
X-Event-Timestamp | Unix seconds for this attempt |
X-Event-Key-Id | Key id from queue config |
X-Event-Signature | ed25519=<base64> over v1.{timestamp}.{event_id}.{sha256_hex(raw_body)} |
Verify with the public key you were given when the signing key was provisioned. HMAC headers above are still present.
4.3 Validate the incoming request
- Read the raw body bytes (do not re-serialise JSON before verifying).
- Require
X-API-Key, X-Payload-Signature, and Idempotency-Key.
- Check
X-API-Key === your ak_live_.
- Verify HMAC with a constant-time compare:
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyDelivery({ apiKey, secret, rawBody, signatureHeader }) {
const expected =
"sha256=" +
createHmac("sha256", secret)
.update(apiKey)
.update(".")
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}
- Deduplicate on
Idempotency-Key: if you already accepted that key, return 2xx again and do not re-run the work (unless the key has a :rerun:<n> suffix — that is a new execution; see idempotency.md).
- Parse JSON, start async work, respond quickly with
202 (or 200 / 204).
- When work finishes, call
POST /api/v1/events/{event_id}/result (§3.4) with completed, error, or custom.
Minimal handler sketch:
import { createServer } from "node:http";
const seen = new Set();
createServer((req, res) => {
if (req.method !== "POST" || req.url !== "/hooks/relay") {
res.writeHead(404); res.end(); return;
}
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
const rawBody = Buffer.concat(chunks);
const apiKeyHeader = req.headers["x-api-key"];
const signature = req.headers["x-payload-signature"];
const idem = String(req.headers["idempotency-key"] ?? "").trim();
if (apiKeyHeader !== apiKey || !verifyDelivery({ apiKey, secret, rawBody, signatureHeader: signature })) {
res.writeHead(401, { "content-type": "application/json" });
res.end(JSON.stringify({ error: { message: "unauthorized" } }));
return;
}
if (!idem) {
res.writeHead(400, { "content-type": "application/json" });
res.end(JSON.stringify({ error: { message: "Idempotency-Key required" } }));
return;
}
if (seen.has(idem)) {
res.writeHead(202, { "content-type": "application/json" });
res.end(JSON.stringify({ accepted: true, replay: true }));
return;
}
seen.add(idem);
const payload = JSON.parse(rawBody.toString("utf8"));
res.writeHead(202, { "content-type": "application/json" });
res.end(JSON.stringify({ accepted: true }));
// Async: do work, then reportResult(payload.event_id, "completed" | "error" | "custom", …)
void processEvent(payload).then(
(result) => reportResult(payload.event_id, "completed", result),
(err) => reportResult(payload.event_id, "error", {
code: err.code ?? "HANDLER_FAILED",
message: err.message ?? "failed",
}),
);
});
}).listen(8787);
A fuller example lives in examples/send-message/webhook-server.mjs (verify delivery HMAC, dedupe, then post a result). Prefer the signing helpers in §2.2–2.4 for callbacks — they must include X-Timestamp and the full request signing string.
5. End-to-end flow
You Relay Your webhook
| POST /queues/{id}/events | |
| (signed + Idempotency-Key) | |
|-------------------------------->| store, enqueue |
| 202 { event_id } | |
| | POST webhook_url |
| | HMAC + Idempotency-Key |
| | + X-Event-Id |
| |----------------------------->|
| | 202 accepted |
| |<-----------------------------|
| | server_status = sent |
| POST /events/{id}/result | |
| status: completed|error|custom | |
|<--------------------------------|------------------------------|
| (your worker calls this) | client_status set |
- Create a queue with your webhook URL.
- Submit events with a business idempotency key.
- Webhook validates HMAC, dedupes, accepts with 2xx, processes asynchronously.
- Webhook reports
completed, error, or custom to /api/v1/events/{event_id}/result.
- Poll
GET /api/v1/events/{event_id} if you need status from another process.
6. Error responses
Typical JSON:
{ "error": { "message": "…" } }
Validation errors may include an issues array. Common statuses: 400 bad request / missing idempotency key, 401 missing or invalid signature / API key / timestamp window, 403 suspended client, 404 unknown queue or event, 409 conflict (idempotency mismatch, paused queue, result already set, or schedule_limit_exceeded with schedule_limit), 429 rate limit (rate_limited, with Retry-After; per API key at the plan's rate_limit_per_second, none for perpetual subscriptions) or event_quota_exceeded.
7. Quick reference — which signature where
| Direction | Header | Message signed | Key |
|---|
Client → relay (/api/v1) | X-Payload-Signature: sha256= | api_key.timestamp.METHOD.path.body | cs_live_ (Method A) |
Client → relay (/api/v1) | X-Payload-Signature: ed25519= | same string | your Ed25519 private key or AWS KMS (Method B) |
| Relay → webhook | X-Payload-Signature: sha256= | api_key.body | cs_live_ |
| Relay → webhook (optional) | X-Event-Signature: ed25519= | v1.timestamp.event_id.sha256(body) | platform delivery key |
Request signing details: §2.1–2.3.