Recursos de desenvolvimento

Recursos e documentação

Fontes: docs/sdk.md + docs/examples.md + docs/api.md

SDK Node.js, exemplos práticos de utilização e a referência completa de /api/v1.

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 sendWhat Relay does
New keyCreates a new event (or queue / result) and returns it
Same key + same bodyDoes not start a new run. Returns the existing accepted request (same event_id)
Same key + different body409 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

MethodHTTPIdempotency-Key
relay.queues.create(body, { idempotencyKey })POST /api/v1/queuesrequired
relay.events.submit(queueId, body, { idempotencyKey })POST /api/v1/queues/{queueId}/eventsrequired
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}/resultrequired
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 caseEvent exampleWhat the webhook doesWhy use Relay
Send a transactional emailemail.sendRenders a template and calls an email providerThe user-facing request can finish without waiting for the provider
Send a push notificationpush.sendSends to APNs, FCM, or another push providerAbsorbs traffic spikes and keeps provider latency outside the main request path
Send an SMS or WhatsApp messagemessage.sendCalls the messaging providerKeeps messaging work outside the main request path and makes its status observable
Process an uploaded imageimage.processDownloads an object, resizes or optimizes it, and uploads the outputsMoves CPU-heavy work out of the upload request
Transcode uploaded audio or videomedia.transcodeDownloads the source, creates output formats, and stores themRuns long or resource-intensive work asynchronously
Scan an uploadfile.scanDownloads a quarantined object and runs malware or content checksThe file can remain unavailable until a verified result arrives
Extract text from a documentdocument.ocrRuns OCR or parsing and stores the extracted dataSeparates slow document processing from the API request
Generate a PDFdocument.generateRenders an invoice, receipt, ticket, or report and stores itAvoids blocking the user while rendering and uploading the file
Generate thumbnails or previewsasset.previewCreates thumbnails, waveform images, or document previewsOne upload can trigger independent derived-asset jobs
Run an AI taskai.summarizeCalls a model, validates the output, and stores the resultHandles variable model latency and reports a separate business result
Process an orderorder.processReserves stock, invokes fulfillment, or coordinates downstream actionsProvides durable delivery and an auditable event status
Capture or reconcile a paymentpayment.reconcileQueries a payment provider and updates internal recordsRetries transport failures without holding the checkout response open
Synchronize a CRM or ERPcustomer.syncUpserts the record in a third-party systemIsolates the application from third-party rate limits and downtime
Update a search indexsearch.indexReads the latest entity state and updates the search providerMakes indexing eventually consistent without delaying writes
Export dataexport.generateBuilds a CSV or archive, stores it, and returns a download referenceSupports long-running exports while exposing their final status
Import a data fileimport.processReads a stored file, validates rows, and persists valid recordsAllows progress or validation failures to be reported asynchronously
Fan out an internal domain eventcustomer.createdA webhook routes the event to analytics, onboarding, or other servicesDecouples the originating request from downstream integrations
Deliver a partner webhookpartner.notifyTransforms the payload and calls a partner endpointAdds durable delivery, retry history, and idempotency around the integration
Record analytics or audit dataactivity.recordEnriches and writes the activity to its destinationKeeps non-critical writes away from the main response path
Invalidate or warm a cachecache.refreshRebuilds cached views or preloads frequently used dataPerforms expensive refreshes after the source change is committed
Send a delayed reminderreminder.send with delay_seconds or deliver_atSends the reminder when Relay delivers the eventAvoids keeping application timers alive
Run recurring housekeepingA schedule emitting maintenance.runDeletes expired data, reconciles records, or builds a digestUses cron schedules with a named IANA timezone
Produce a daily or weekly digestA schedule emitting digest.generateQueries the period, creates the digest, and sends or stores itTurns 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:

  1. A webhook HTTP 2xx means only that delivery was accepted. Report the business outcome separately with POST /api/v1/events/{event_id}/result.
  2. Delivery is at least once. Verify the delivery signature and deduplicate using the incoming Idempotency-Key before performing side effects.
  3. Use a different stable idempotency key, such as result_<event_id>, when posting the result callback.
  4. Keep payloads small. Store large files and large generated outputs elsewhere and pass stable references in events and results.
  5. 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.
  6. Design external side effects to be idempotent. Prefer provider idempotency keys, unique business-operation records, or a local deduplication table.
  7. 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.
  8. 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:

  1. Copy docs/AGENT.md into the target project (or keep a path/URL your agent can read).
  2. 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.
  3. 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

ValuePrefixRole
API keyak_live_…Sent as X-API-Key. Identifies the tenant. Alone it authorises nothing.
API secretcs_live_…HMAC key for sha256= signatures (client → relay and relay → webhook). Never sent on the wire.
Request key pair (optional)Ed25519 PEMPrivate 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:

HeaderRequiredNotes
X-API-KeyyesYour ak_live_…
X-TimestampyesUnix seconds when you signed. Accepted within ±300 s of the server clock
X-Payload-Signatureyessha256=<hex> or ed25519=<base64> — pick one of the two methods below
Idempotency-Keywrites onlyRequired on create queue, submit event, and post result. Max 200 characters. Not used on schedules or GET / DELETE
Content-Typewhen bodyapplication/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-SHA256Method B — Ed25519
Header valuesha256=<hex>ed25519=<base64>
Key you holdAPI secret cs_live_… (symmetric)Ed25519 private key (asymmetric)
What the relay storesEncrypted cs_live_ (must be recoverable to verify HMAC)Only the public key PEM on the tenant / queue
Works onEvery /api/v1 endpointEvery /api/v1 endpoint once a public key is configured
Best whenSimple scripts, local tools, you already have cs_live_ in the processProduction services that must not hold a long-lived shared secret — especially AWS KMS (or another HSM / cloud KMS)
Failure modeInvalid secret → 401Missing 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

  1. Create (or import) an Ed25519 signing key in KMS; grant your runtime kms:Sign and kms:GetPublicKey.
  2. Export the public key (SPKI / PEM) and set it as request_public_key_pem on the queue (create-queue body or dashboard).
  3. 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

MethodPathIdempotency-KeyPurpose
POST/api/v1/queuesrequiredCreate (or return) a queue
POST/api/v1/queues/{queue_id}/eventsrequiredSubmit an event
GET/api/v1/events/{event_id}—Fetch event status (?attempts=1 includes attempts)
POST/api/v1/events/{event_id}/resultrequiredReport 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:

FieldRequiredNotes
nameyes1–120 characters
webhook_urlyesAbsolute URL of your receiver
request_public_key_pemnoEd25519 public PEM for verifying later ed25519= requests on this queue
webhook_auth_modenonone (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:

FieldRequiredNotes
event_typeyesFree string (1–200). Special case: send_message requires event to be only { "notification_id": "…" }
eventnoArbitrary JSON payload delivered to the webhook
delay_secondsnoInteger ≥ 0; delay from now. Max 90 days. Mutually exclusive with deliver_at
deliver_atnoRFC 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 caseResult
Same key, same body202 with the existing event_id
Same key, different body409 with existing_event_id
Queue paused409

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.

CaseResult
First result while sent and no client result yet200, stored
Same key + same body again200, "replay": true
Same key, different body / different key after a result409
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" }
  ]
}
FieldRule
tag1–64 characters of a-z, 0-9, _ . : -
atOptional. RFC 3339 with an explicit offset. Omitted means the time the relay received it.
batch1–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.

CaseResult
Valid batch inside the window200
Same checkpoints again200, "accepted": 0
Bad tag, missing offset, at out of range, empty or oversized batch400
Not delivered yet, or closed more than 10 minutes ago409
Would exceed 100 checkpoints for the event409

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" }
}
FieldRequiredNotes
nameyesUnique per client
cronyes5-field cron, or @hourly / @daily / …
timezonenoIANA name, default UTC
event_typeyesSame rules as submit event
eventyesPayload 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:

HeaderMeaning
X-API-KeyYour ak_live_…
X-Payload-Signaturesha256=<hex> HMAC of ak_live_ + "." + raw_body with cs_live_
Idempotency-KeyKey from event creation (retries keep it; reruns append :rerun:<n>)
X-Event-IdStable event_id
X-Event-AttemptAttempt number for this execution
X-Queue-IdQueue id
Content-Typeapplication/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:

HeaderMeaning
X-Event-TimestampUnix seconds for this attempt
X-Event-Key-IdKey id from queue config
X-Event-Signatureed25519=<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

  1. Read the raw body bytes (do not re-serialise JSON before verifying).
  2. Require X-API-Key, X-Payload-Signature, and Idempotency-Key.
  3. Check X-API-Key === your ak_live_.
  4. 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);
}
  1. 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).
  2. Parse JSON, start async work, respond quickly with 202 (or 200 / 204).
  3. 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           |
  1. Create a queue with your webhook URL.
  2. Submit events with a business idempotency key.
  3. Webhook validates HMAC, dedupes, accepts with 2xx, processes asynchronously.
  4. Webhook reports completed, error, or custom to /api/v1/events/{event_id}/result.
  5. 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

DirectionHeaderMessage signedKey
Client → relay (/api/v1)X-Payload-Signature: sha256=api_key.timestamp.METHOD.path.bodycs_live_ (Method A)
Client → relay (/api/v1)X-Payload-Signature: ed25519=same stringyour Ed25519 private key or AWS KMS (Method B)
Relay → webhookX-Payload-Signature: sha256=api_key.bodycs_live_
Relay → webhook (optional)X-Event-Signature: ed25519=v1.timestamp.event_id.sha256(body)platform delivery key

Request signing details: §2.1–2.3.