A callback tells your server when a run has finished, so you do not need to poll or hold an event stream open. Ephemeral sends one signed POST request with the run object after the run reaches a final status.
Request a callback
Add callback to the run request.
callback.urlstringrequiredcallback.secretstringrequired{
"workflow": "investigate-payment",
"input": { "payment_id": "pay_123" },
"max_cost_micros": 500000,
"callback": {
"url": "https://example.com/hooks/ephemeral",
"secret": "3f9a6c1e8b2d47f0a5c3e9d1b7f2a6c4"
}
}The secret is never returned by any read, including the run definition. Callbacks are not part of saved workflows; set one on each run that needs it.
What you receive
The body is the run object as it is at delivery time, sent as JSON with these headers:
| Header | Value |
|---|---|
X-Ephemeral-Run | The run ID. |
X-Ephemeral-Timestamp | Unix time in seconds when this attempt was signed. |
X-Ephemeral-Signature | sha256= followed by the hex HMAC-SHA256 of the timestamp, a period and the raw body, keyed with your secret. |
Content-Type | application/ |
<X-Ephemeral-Timestamp>.<raw request body>Verify the signature
Compute the HMAC over the raw bytes of the body before parsing it, and compare in constant time.
import crypto from "node:crypto";
import express from "express";
const app = express();
app.post("/hooks/ephemeral", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.get("X-Ephemeral-Timestamp") ?? "";
const signature = req.get("X-Ephemeral-Signature") ?? "";
const expected =
"sha256=" +
crypto
.createHmac("sha256", process.env.EPHEMERAL_CALLBACK_SECRET)
.update(timestamp + ".")
.update(req.body)
.digest("hex");
const valid =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
if (!valid || !fresh) return res.sendStatus(401);
const run = JSON.parse(req.body);
// Record run.id first and skip it if you have already handled it.
res.sendStatus(204);
});Each attempt is signed with the current time, so rejecting old timestamps limits replay without rejecting genuine retries.
Delivery and retries
Any 2xx response counts as delivered. Anything else, including a timeout or a redirect, is retried: first after 10 seconds, then with the delay doubling each time up to about 43 minutes between attempts. Pending deliveries are stored durably, so an engine restart does not lose them.
X-Ephemeral-Run to make handling idempotent.billing_pending is true in the body, read the run again later with GET /v1/runs/{id} for its final cost.