1. Guides
  2. Verifying callbacks

Every callback is an HTTPS POST signed with your endpoint’s signing secret. Your receiver should prove the signature is valid before it parses or acts on the body. Anyone who can reach your URL can send it a request.

The quickest correct receiver in JavaScript is the one @greatwait/api-client builds for you. The language-neutral checklist covers the same steps for other languages.

​
What a callback looks like

HeaderValue
Content-Typeapplication/json
Callback-Delivery-IdThis delivery, such as dlv_.... Every attempt gets a new one.
Callback-Occurrence-IdThe occurrence, such as occ_.... It is the same on every attempt for that occurrence.
Callback-TimestampWhen it was signed, in RFC 3339 UTC with milliseconds, such as 2026-07-28T15:00:00.000Z.
Callback-Key-IdWhich signing key produced the signature, such as csk_....
Callback-Signaturev1= followed by 64 lowercase hexadecimal characters.

The body is a JSON envelope:

{
  "version": "2026-08-23",
  "type": "schedule.occurrence",
  "scheduleId": "sch_...",
  "occurrenceId": "occ_...",
  "plannedFor": "2026-07-28T15:00:00.000Z",
  "attempt": 1,
  "completion": {
    "token": "...",
    "url": "...",
    "deadline": "2026-07-28T15:00:05.000Z"
  },
  "trigger": { "kind": "automatic" },
  "data": {}
}

data is the JSON you set on the schedule. trigger says why this delivery exists: automatic, or redelivery with a sourceAttemptId when a person asked for it. completion is only needed if you finish the work later.

Do not reject the envelope because of a version or a field you do not recognise. A receiver that does refuses real deliveries when the envelope changes.

​
In JavaScript

Install the SDK (Node 22.13 or newer, or any runtime with fetch and Web Crypto):

npm install --save-exact @greatwait/api-client

This receiver verifies each callback, suppresses duplicates, and runs your handler at most once per run:

import { createServer } from "node:http";
import {
  createCallbackReceiver,
  createInMemoryCallbackStores,
  createNodeCallbackMiddleware,
  createRotatingCallbackKeyResolver,
} from "@greatwait/api-client";

// In-memory stores are for trying things out and single-instance receivers:
// they forget everything on restart and are not shared between instances.
// In production, implement the same two interfaces with your own database.
const { occurrences, replay } = createInMemoryCallbackStores();

async function doYourWork(data) {
  // Your code.
}

const receive = createCallbackReceiver({
  occurrences,
  replay,
  // The key id and secret from creating the endpoint, read from the environment.
  resolveKey: createRotatingCallbackKeyResolver({
    keys: {
      current: {
        id: process.env.GREATWAIT_ENDPOINT_KEY_ID,
        secret: process.env.GREATWAIT_ENDPOINT_SECRET,
      },
    },
  }),
  handler: async (delivery) => {
    // Runs only after the signature, timestamp and duplicate checks pass.
    // Throwing answers 500, so Greatwait tries again.
    await doYourWork(delivery.envelope.data);
  },
});

const callbacks = createNodeCallbackMiddleware(receive);

createServer((request, response) => {
  if (request.method === "POST" && request.url === "/greatwait") {
    void callbacks(request, response);
    return;
  }
  response.statusCode = 404;
  response.end();
}).listen(3000);

The handler receives delivery.occurrenceId, delivery.deliveryId, delivery.trigger, delivery.attempt, delivery.envelope, and delivery.rawBody, the exact bytes that were signed.

Other setups:

  • Express: mount createNodeCallbackMiddleware(receive) on the callback route before express.json() or any other body parser. Once a parser has consumed the body, the exact bytes are gone.
  • Workers, Deno, Bun, Hono, or anything using Request and Response: use createFetchCallbackHandler(receive), which returns a function from Request to Response.
  • Other frameworks: read the body with readExactCallbackBody, then call receive with the bytes and headers.
  • Verification only: createCallbackVerifier checks headers, timestamp, and signature and claims the delivery id. It leaves duplicate occurrences and status codes to you.

createCallbackReceiver also accepts maxBodyBytes, leaseMs (how long an unfinished occurrence claim is held, 30 seconds by default), strictOnceOnly, and onHandlerError (called with whatever your handler threw, since the middleware logs nothing itself).

​
Checklist for any language

Do these steps in this order. The signature must be checked against the exact bytes you received, so the body is parsed near the end.

  1. Read the raw body bytes before any body parser runs. Never re-serialize JSON before verifying: {} and { } are different bytes with different signatures.

  2. Enforce a size limit while reading. If the body exceeds it, answer 413 without buffering the rest.

  3. Check the headers. Content-Type is application/json, and all five Callback-* headers are present and well formed. Refuse a malformed Callback-Timestamp or Callback-Delivery-Id before computing anything.

  4. Check freshness. Refuse a Callback-Timestamp more than 300 seconds away from your clock.

  5. Pick the key by Callback-Key-Id, looking only among the keys for the endpoint this route serves. An unknown key id is refused.

  6. Compute and compare the signature, using a constant-time comparison:

    signed bytes = ASCII( Callback-Timestamp + "." + Callback-Delivery-Id + "." ) followed by the raw body bytes
    expected     = "v1=" + lowercase hex( HMAC-SHA-256( key = UTF-8 bytes of the signing secret, signed bytes ) )
    
  7. Check the occurrence named in the body matches Callback-Occurrence-Id.

  8. Claim the delivery id atomically, for example with a unique-key insert. If it was already claimed and that delivery finished, answer 200. If it is still being handled, answer 503.

  9. Parse the body and read trigger.

  10. Claim the run atomically. For an automatic delivery the run is the occurrence. For a redelivery it is the occurrence together with the envelope’s attempt. If the run is already done, answer 200. If another worker holds it, answer 503. See Duplicates and replays.

  11. Do the work. Answer 200 when it is done. If it fails, release your claims and answer 500.

​
What to answer

Greatwait decides whether to retry based only on your status code:

SituationAnswerGreatwait reads it as
Work done200success
A duplicate: a new delivery for a run you already finished200success
A replay: the same delivery id again, already finished200success
Timestamp outside the 300-second window408retry
Signature does not verify, or headers missing or malformed401retry
Body verified but is not a valid envelope400final failure
Body over your size limit413final failure
Another worker is still handling this occurrence503retry
Your work failed500retry

Never answer 4xx to a duplicate or a replay. Greatwait would record a failure for work that succeeded. For how other statuses are classified and when retries stop, see Failures and retries.

Keep error responses free of exception messages and internal detail.

​
Duplicates and replays

Delivery is at least once, so the same occurrence can reach you more than once. There are three cases:

  • Replay: the same Callback-Delivery-Id arrives again, for example a request duplicated in transit. If you finished it, answer 200 without doing anything.
  • Automatic retry: a new delivery id with the same occurrence and trigger.kind automatic, typically after an attempt whose answer Greatwait never received. Do not run the work again. Answer 200.
  • Redelivery: trigger.kind is redelivery. A person asked for the occurrence to be delivered again with occurrences.redeliver or the dashboard, usually after fixing a receiver. Run the work again.

To tell a new redelivery from a delayed copy of an old one, use the envelope’s attempt. It only increases within an occurrence, and every copy of one redelivery carries the same value. For each occurrence, remember two things:

  • whether the automatic run has finished, and
  • the highest redelivery attempt you have finished.

Treat any redelivery whose attempt is at or below that number as a duplicate. This recognises a copy that arrives late, however many redeliveries finished in between.

If running an occurrence twice is never acceptable, even when a person asks, such as a payment, suppress every delivery once anything has finished for that occurrence. In the SDK, set strictOnceOnly: true.

A claim protects work you have finished, not work you have started. If your process performs its effect and dies before recording that it finished, the work runs again once the claim expires. If an effect must happen exactly once, store the occurrence id and attempt in the same transaction as your own data, or pass them downstream as an idempotency key.

You can let a delivery id’s claim expire once its timestamp is outside the 300-second window, because that request can never be accepted again.

​
Body size

Refuse oversized bodies while reading, not after. The SDK’s default limit is 69,632 bytes, which covers a 64 KiB data payload plus room for the rest of the envelope. If you set a lower limit with maxBodyBytes, larger callbacks are answered 413 and fail without retry.

​
Rotate signing keys

endpoints.rotateSigningKey creates a new signing key for an endpoint.

  • The response includes the new key’s secret and keyId, marked shownOnce: true. It is the only API response that carries this secret.
  • The new key signs new deliveries immediately. The old key stays valid for receivers until the overlap ends: 24 hours by default, or shorter if you send overlapSeconds. The endpoint shows this as signingKeys.next.overlapEndsAt.
  • When the overlap ends, the new key becomes the current key. You cannot start another rotation until then.

Because the new key is used straight away, deploy it to your receiver promptly. Until you do, deliveries signed with it are answered 401. Greatwait retries them, but each one counts against the receiver’s failure budget.

In the SDK, give the resolver both keys during the overlap:

import { createRotatingCallbackKeyResolver } from "@greatwait/api-client";

const resolveKey = createRotatingCallbackKeyResolver({
  keys: {
    current: {
      id: process.env.GREATWAIT_ENDPOINT_KEY_ID,
      secret: process.env.GREATWAIT_ENDPOINT_SECRET,
    },
    next: {
      id: process.env.GREATWAIT_ENDPOINT_NEXT_KEY_ID,
      secret: process.env.GREATWAIT_ENDPOINT_NEXT_SECRET,
      overlapEndsAt: process.env.GREATWAIT_ENDPOINT_OVERLAP_ENDS_AT,
    },
  },
});

It accepts the old key only until overlapEndsAt, and the new key from the start.

Once your receiver has the new key, you can record that with endpoints.acknowledgeSigningKeyRotation. It only marks the reminder done: it does not end the overlap or change which key signs.

If a rotation call failed and you never saw its response, read the endpoint and check signingKeys before rotating again. A key may already have been created. A signed-in member can reveal the endpoint’s signing secrets, including both keys during an overlap, from the endpoint’s page in the dashboard.

​
Work that outlasts the request

If your work takes longer than you want to hold the request open, you can accept it now and report the result later.

  • Answer exactly 202 with Content-Type: application/json and a body containing "greatwait": { "status": "pending" }. Any other 2xx, including a 202 without that marker, is immediate success. In the SDK, return delivery.defer() from your handler.
  • When the work finishes, POST to the envelope’s completion.url with the completion.token as a bearer credential, before completion.deadline. In the SDK, call reportCallbackCompletion(delivery.completion, "succeeded"), or pass "failed" to have Greatwait retry as if you had answered 500.
  • Treat the token as a secret: do not log it or put it in a URL.
  • If you never report, the attempt is recorded as ambiguous, and the retry that follows may be for work you already did.