Outbound webhooks
Being told when something changes, how to verify the signature, and how to recover the events you missed.
5 min read Last updated
On this page
Register an endpoint and Evident POSTs to it when something happens, rather than you polling for it.
Webhooks are managed through the API today — there is no settings screen for them yet. Register one with an API key:
curl -X POST https://api.evidentugc.com/api/v1/outbound-webhooks \
-H "Authorization: Bearer evnt_..." \
-H "x-store-env-id: YOUR_STORE_ENV_ID" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/hooks/evident", "events": ["review.approved"], "label": "Review sync" }'
The response includes the webhook’s signing secret. It is shown only in that response — listing webhooks never returns it — so store it with your other secrets straight away. If you lose it, rotate as described below. GET, PUT /:id and DELETE /:id on the same path list, change and remove webhooks.
Events
| Event | Fires when |
|---|---|
review.created | A review is submitted |
review.approved | A review is published |
review.rejected | A review is rejected |
review.updated | A review is edited |
review.video_ready | An attached video finishes transcoding and is playable |
order.placed | An order is first seen |
order.completed | An order reaches a completed or shipped status |
gallery.submitted | A customer submits a photo to a gallery |
gallery.approved | A merchant publishes a submitted item |
redemption.created | A customer spends points |
redemption.used | A redemption is used |
redemption.expired | A redemption lapses unused |
Subscribe per event. A webhook subscribes to the events you choose, not to everything.
Two of these are worth calling out:
review.video_readyexists becausereview.createdcan arrive while the video is still transcoding. If you re-render a review with playback, listen for this rather than retrying onreview.created.redemption.createdcarries the discount code and afulfillment_status. On a headless store, or for any reward the platform cannot express, that arrives asunsupported— your signal that you must issue the discount yourself.
The payload
{
"event_id": "6b1e...",
"event": "review.approved",
"timestamp": "2026-09-02T10:14:22.000Z",
"data": { }
}
event_id identifies one firing of an event. Every webhook subscribed to that event receives the same event_id, which is what makes the rotation overlap safe to deduplicate on it.
It is not the ID the event log uses. Events there carry a fixed ID such as evt_review_created_<review id>, so the same review arrives with one event_id by webhook and another from the log. If you process both, deduplicate on the event name plus data.id.
Headers
| Header | Contents |
|---|---|
x-evident-signature-v2 | t=<unix seconds>,v1=<hex> — HMAC-SHA256 of `${t}.${rawBody}`, keyed with your webhook secret. Verify this one. |
x-evident-event | The event name, so you can route before parsing |
x-evident-webhook-id | Unique per HTTP attempt — not the same as event_id |
x-evident-signature | Deprecated, removed 15 January 2027. HMAC-SHA256 of the raw body alone. See migrating. |
Verifying the signature
Two things make a delivery genuine: the digest, and the age. Check both.
The signed string is the timestamp from the header, a literal ., then the raw request body — the bytes as they arrived, before any JSON parsing. Frameworks that parse and re-serialize change the bytes and break verification.
import crypto from 'node:crypto';
const TOLERANCE_SECONDS = 300;
function verify(rawBody, header, secret) {
if (typeof header !== 'string') return false;
// t=<unix>,v1=<hex>, in any order. Ignore fields you do not recognise, so a
// future digest version does not make you reject the delivery outright.
const fields = new Map(
header
.split(',')
.map((part) => part.split('=', 2))
.filter((pair) => pair.length === 2)
.map(([k, v]) => [k.trim(), v.trim()]),
);
const t = fields.get('t');
const v1 = fields.get('v1');
if (!t || !v1 || !/^\d+$/.test(t)) return false;
// Reject stale and future-dated deliveries. This is the replay defence: a
// captured request stops verifying once it is older than your tolerance.
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
if (age > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(v1, 'utf8');
// timingSafeEqual throws on unequal lengths. The length is not secret; the digest is.
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
Compare in constant time, as above. A plain === leaks timing information. Return false on every failure rather than throwing: the endpoint is public by necessity, and an unauthenticated request should not be able to crash your handler.
Reject anything that does not verify. The signature is the only thing separating a real event from anyone who guesses your URL.
Why the timestamp is in the signature
Because a signature over the body alone verifies forever. Anyone who observes one delivery — a logging proxy, an error tracker that captures request bodies, a compromised staging receiver — can send those exact bytes again at any point in the future and they still check out. For redemption.created, which carries a discount code, and for review.approved, which publishes content, that has direct consequence.
Binding the timestamp into the digest means a captured delivery expires. It cannot be re-signed with a fresh timestamp without the secret, and it cannot keep its old one past your tolerance.
Our tolerance is 300 seconds. We publish it so you do not have to guess; a tolerance you guess is a tolerance set wrong. Check in both directions — a future-dated timestamp is as much a sign of forgery as an old one is of a replay.
Still deduplicate on event_id. The tolerance stops replays from last week; it does not stop the same event arriving twice in the same minute.
Migrating from x-evident-signature
Both headers are sent on every delivery until 15 January 2027, when the body-only one stops. If you verify x-evident-signature today, you have until then.
- Switch your handler to
x-evident-signature-v2, using the sample above. - If you want to deploy carefully, accept either: verify v2 when the header is present, and fall back to the old one only when it is absent.
- Do not fall back when a v2 header is present but malformed — reject instead. Falling back there lets anyone downgrade your check by sending junk in v2 alongside a captured body-only signature.
The payload, the event names and the other headers are unchanged. This is only about which header you hash against.
Rotating the secret
There is no rotate endpoint. To replace a secret, register a second webhook for the same URL and events, accept either secret while you deploy the new one, then delete the old webhook. Expect each event twice during the overlap — deduplicating on event_id absorbs it.
Where requests come from
Deliveries come from Evident’s API servers, which do not have fixed IP addresses. Do not build an IP allow-list; it will break without warning. The signature is the check.
Delivery, honestly
Delivery is fire-and-forget with a 15-second timeout. There is no automatic retry queue today. A non-2xx response or a timeout is recorded against the webhook — you can see the last error and last fired time on the subscription — but it is not re-attempted.
So: do not treat webhooks as your only path. Acknowledge fast (queue the work, return 200 immediately) and reconcile periodically.
Recovering what you missed
There is an event log for exactly this: GET /api/v1/outbound-webhooks/events. It replays a time-ordered stream of webhook-shaped events for a store, reconstructed from the underlying rows rather than from delivery attempts — so it covers events that were never delivered at all, including ones from before your subscription existed.
It covers six of the twelve events: review.created, review.approved, order.placed, order.completed, gallery.submitted and gallery.approved. The other six — review.rejected, review.updated, review.video_ready and the three redemption.* events — have no record to rebuild them from, so for those the webhook delivery is the only source. If you depend on one of them, keep your receiver’s uptime in mind accordingly.
It is cursor-paginated: pass a since timestamp (exclusive) and optionally a limit (1–1000, default 100). The response is { "events": [...], "next_cursor" }; while next_cursor is not null, there is more. Events are ordered by timestamp, then ID.
One edge to handle: next_cursor is the timestamp of the last event on the page, and since is exclusive. If several events share that exact millisecond and the page ended partway through them, passing next_cursor straight back skips the rest. Resuming from one millisecond earlier and deduplicating avoids it, and the sample below does.
A daily reconciliation against this log is a few lines of code and removes an entire class of “we missed one and never noticed”:
async function reconcile(since, handle) {
let cursor = since; // the last timestamp you finished processing
do {
const url = new URL('https://api.evidentugc.com/api/v1/outbound-webhooks/events');
url.searchParams.set('since', cursor);
url.searchParams.set('limit', '500');
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${process.env.EVIDENT_API_KEY}`,
'x-store-env-id': process.env.EVIDENT_STORE_ENV_ID,
},
});
if (!res.ok) throw new Error(`Event log returned ${res.status}`);
const { events, next_cursor } = await res.json();
for (const event of events) await handle(event); // deduped on event + data.id
// Step back one millisecond so events sharing the boundary timestamp are
// not skipped. The overlap is re-read, which the dedupe absorbs.
cursor = next_cursor && new Date(Date.parse(next_cursor) - 1).toISOString();
} while (cursor);
}
Store the timestamp of the last event you processed and start from it next time.
Building the receiver
- Verify
x-evident-signature-v2first, over the raw body, before parsing or acting — including the timestamp check. - Return 200 quickly. Do the work asynchronously; there is a 15-second timeout.
- Deduplicate — on
event_idfor deliveries, and on the event name plusdata.idif you also read the event log. - Handle unknown events. More get added; do not throw on one you do not recognise.
- Reconcile from the event log on a schedule.
Something missing or out of date? Email [email protected] — docs corrections go straight to the team that builds the feature.