Webhook Events
DRE pushes outbound notifications to your systems over HTTPS as your data changes — when a promotion is activated, a transaction is confirmed, a budget crosses a threshold, a coupon is redeemed, or a bulk import finishes. This page is the integrator reference for which events exist, the exact JSON each one publishes, how requests are signed, and the delivery guarantees.
For the operator workflow (creating subscriptions, monitoring deliveries, replaying failures from the UI) see the user manual, section 7.4 "External notifications — webhook subscriptions and failed deliveries".
Two delivery channels
DRE delivers webhooks through two independent channels. They are signed differently, so verify each with the matching scheme.
| Channel | Source | How you opt in | Signature scheme |
|---|---|---|---|
| Subscription events | Promotion / Transaction / Budget / Coupon lifecycle + import-job fan-out | Create a WebhookSubscriptions row (admin UI or POST /api/v1/WebhookSubscriptions) | X-DRE-Signature: sha256=<hex> |
| Per-job callback | A single bulk-import job | Set callbackUrl (+ callbackSecret) on the ImportJobs request | X-DRE-Signature: t=<unix>,v1=<hex> |
All deliveries are POST with Content-Type: application/json and an
at-least-once guarantee — design your receiver to be idempotent (see
Delivery, retries & dead-letter).
Webhooks are a secondary side effect. They are enqueued after the triggering operation has committed and delivered by a background worker, so a slow or unreachable receiver never delays or rolls back the business operation (a promotion activation, a confirm, an import). Subscription lifecycle events (promotion, transaction, budget, coupon) are enqueued for delivery immediately after the operation commits — near-real-time, not on a polling cadence — and a background sweep re-drives any row whose immediate enqueue did not land. Per-job import callbacks are delivered by the same background worker. Expect a short delay between the event and its delivery, not synchronous callback timing.
Prerequisites
Webhook delivery depends on infrastructure that is separate from the API that accepts your subscriptions:
- Redis must be configured (
REDIS_URLset). The delivery queue is backed by Redis (BullMQ). - The delivery worker process must be running:
npm run start:worker.
If either is missing, subscriptions and outbox rows are still persisted
(POST /api/v1/WebhookSubscriptions keeps working and events are recorded),
but no events are delivered — outbox entries stay in PENDING and your
receiver is never called. When REDIS_URL is unset and at least one active
subscription exists, the server logs a startup warning, and
GET /health reports checks.webhookDelivery.status: "inactive" (this is
informational only — it does not mark the service unhealthy).
Subscribing to events
A subscription is a WebhookSubscriptions row scoped to your tenant. The key
fields:
| Field | Required | Description |
|---|---|---|
callbackUrl | yes | HTTPS endpoint DRE POSTs to. Must be a public address — loopback, RFC1918 and link-local/metadata IPs are rejected (see Security). |
secret | yes | Shared secret used to compute the sha256= HMAC. Min 16 characters. Write-only — never returned after creation; PATCH a new value to rotate. |
eventFilter | yes | Which events this subscription receives (default *). See the grammar below. |
active | no | Toggle delivery on/off without deleting (default true). |
maxAttempts | no | Per-subscription retry cap (default 5). |
backoffPolicy | no | EXPONENTIAL (default) or FIXED. |
retryIntervalMs | no | Interval for FIXED backoff, in milliseconds. |
Event-filter grammar
| Pattern | Matches |
|---|---|
* | Every event your tenant emits |
<entity>.<status> | One exact event, e.g. ImportJobs.SUCCEEDED |
<entity>.* | Every status of an entity, e.g. ImportJobs.* |
Only the import-job events carry a dot (ImportJobs.SUCCEEDED). The
promotion, transaction, budget and coupon events are dot-free
snake-ALLCAPS (PROMOTION_ACTIVATED, COUPON_REDEEMED, …). A prefix
pattern such as PROMOTION.* or coupon.* therefore matches nothing.
To receive these events use * or the exact event name.
Event catalogue
Every event below is delivered to matching subscriptions. The Reachable via column shows the filter patterns that select it.
Event (X-DRE-Event) | Fires when | Reachable via |
|---|---|---|
PROMOTION_APPROVED | A promotion is approved (PENDING_APPROVAL → APPROVED) | * or exact |
PROMOTION_REJECTED | A promotion is rejected | * or exact |
PROMOTION_ACTIVATED | A promotion is activated | * or exact |
PROMOTION_DEACTIVATED | A promotion is deactivated | * or exact |
PROMOTION_EXPIRED | A promotion is auto-expired by the scheduler (validTo elapsed, ACTIVE → EXPIRED) | * or exact |
TRANSACTION_CONFIRMED | A POS transaction is confirmed (/pos/v2/confirm success) | * or exact |
BUDGET_THRESHOLD_REACHED | A budget crosses a configured threshold or is exhausted | * or exact |
COUPON_ISSUED | A coupon is issued | * or exact |
COUPON_REDEEMED | A coupon is redeemed | * or exact |
COUPON_CANCELLED | A coupon reservation is cancelled | * or exact |
COUPON_EXPIRED | A single coupon code reaches the terminal EXPIRED state (auto-expire past validTo, or expire-during-activation) | * or exact |
PROMOTION_SUBMITTED | A promotion is submitted for approval (DRAFT → PENDING_APPROVAL) | * or exact |
PROMOTION_ARCHIVED | A promotion is archived (status changed to ARCHIVED) | * or exact |
CAMPAIGN_ACTIVATED | A campaign transitions to ACTIVE (draft activation or status change) | * or exact |
CAMPAIGN_COMPLETED | A campaign transitions to COMPLETED | * or exact |
BUDGET_EXHAUSTED | A budget reaches 100% consumption (distinct terminal event; see note under Budget) | * or exact |
COUPON_BATCH_COMPLETED | A coupon auto-refill batch finishes generating codes | * or exact |
COUPON_POOL_LOW | The active code count falls below the refill threshold (proactive low-stock signal) | * or exact |
ImportJobs.SUCCEEDED | A bulk-import job completes successfully | *, ImportJobs.*, or exact |
ImportJobs.FAILED | A bulk-import job fails | *, ImportJobs.*, or exact |
ArticleImportJobs.SUCCEEDED | An article-catalog import job completes successfully | *, ArticleImportJobs.*, or exact |
ArticleImportJobs.FAILED | An article-catalog import job fails at job level | *, ArticleImportJobs.*, or exact |
LOYALTY_POINTS_AWARDED | A confirmed POS transaction granted loyalty points (fires alongside TRANSACTION_CONFIRMED, only when points > 0) | * or exact |
TRANSACTION_RETURNED | A confirmed POS basket contained return lines (fires alongside TRANSACTION_CONFIRMED, only when the basket has return lines) | * or exact |
PROMOTION_UPDATED | A promotion edit changed a material field (discountValue, validFrom, validTo, exclusivityLevel, ExclusionGroup_ID) — cosmetic edits (name/description) do not fire it | * or exact |
coupon.activated, coupon.reserved and coupon.issued.notification
are internal state transitions emitted on the in-process bus only — they are
not bridged to webhooks (the reservation event in particular fires on every
basket scan and would flood your endpoint). Do not build flows that wait for
them. (coupon.expired is bridged — see COUPON_EXPIRED above.)
WEBHOOK_DELIVERY_DEAD_LETTER (a.k.a. WEBHOOK_DEAD_LETTER) is a
delivery-health signal, not a subscribable domain event. It is surfaced
only via the operator notification channel and the DEAD_LETTER outbox row
status — it is deliberately not fanned out to subscriptions (a dead-letter
event whose own delivery could fail would amplify into a loop). See
Delivery, retries & dead-letter.
Published data models
Each subscription delivery body is the event payload with a timestamp
(ISO 8601, added at emit time) appended. The canonical event name is always in
the X-DRE-Event header; coupon bodies additionally repeat it in an event
field.
Event envelope (actor, correlationId, resourceVersion)
Recent events carry a small envelope of context fields, stamped centrally so you can correlate and audit a delivery without a callback round-trip:
| Field | Type | Notes |
|---|---|---|
actor | string | Who caused the transition. The request user's ID for interactive changes, or a job name (e.g. promotion-deactivate-job) for scheduler-originated events. Always present (falls back to system). |
correlationId | string | The originating HTTP request's correlation ID, for cross-system log correlation. Present only when a request context exists — omitted entirely (not null) for events with no request context. |
resourceVersion | string | The affected entity's last-modified stamp (modifiedAt), where it is cheaply in scope. Omitted for engine-level events (e.g. budget) where it does not apply. |
These fields are additive — fields are present only where the data is
genuinely available, never as null placeholders. Treat them as optional in
your receiver. They are stamped on the newer event families
(transaction/loyalty, PROMOTION_UPDATED, scheduler events); older event bodies
may omit them.
Promotion lifecycle
{
"entityId": "9b1c…-promotion-uuid",
"target": "APPROVED",
"approver": "jane.doe",
"timestamp": "2026-06-09T10:00:00.000Z"
}
{
"entityId": "9b1c…-promotion-uuid",
"approver": "jane.doe",
"comment": "Discount too aggressive for Q3",
"timestamp": "2026-06-09T10:00:00.000Z"
}
{
"entityId": "9b1c…-promotion-uuid",
"timestamp": "2026-06-09T10:00:00.000Z"
}
{
"entityId": "9b1c…-promotion-uuid",
"reason": "VALIDITY_ELAPSED",
"deactivatedAt": "2026-06-09T10:00:00.000Z",
"minorVersion": 9,
"timestamp": "2026-06-09T10:00:00.000Z"
}
{
"entityId": "9b1c…-promotion-uuid",
"author": "jane.doe",
"timestamp": "2026-06-09T10:00:00.000Z"
}
{
"entityId": "9b1c…-promotion-uuid",
"timestamp": "2026-06-09T10:00:00.000Z"
}
{
"entityId": "9b1c…-promotion-uuid",
"changedFields": ["discountValue", "validTo"],
"resourceVersion": "2026-06-10T07:09:11.093Z",
"actor": "jane.doe",
"correlationId": "8e0a08b4-1691-4f0b-920b-1f5b5414418b",
"timestamp": "2026-06-09T10:00:00.000Z"
}
PROMOTION_UPDATED is opt-in and field-allowlisted: it fires only when an
edit genuinely changes the value of a material field. Re-saving a promotion
without changing a material field (or changing only name / description)
emits nothing. changedFields lists exactly the material fields that changed.
| Field | Type | Notes |
|---|---|---|
entityId | string (uuid) | The promotion ID |
target | string | Status after approval (APPROVED); PROMOTION_APPROVED only |
approver | string | User who approved/rejected; approve + reject only |
comment | string | Rejection reason; PROMOTION_REJECTED only |
reason | string | Why it expired (VALIDITY_ELAPSED); PROMOTION_EXPIRED only |
deactivatedAt | string (ISO 8601) | When the scheduler expired it; PROMOTION_EXPIRED only |
author | string | User who submitted the promotion; PROMOTION_SUBMITTED only |
changedFields | string[] | Material fields that changed; PROMOTION_UPDATED only. One or more of discountValue, validFrom, validTo, exclusivityLevel, ExclusionGroup_ID |
actor / correlationId / resourceVersion | string | Envelope fields — present where available |
timestamp | string (ISO 8601) | Emit time |
Campaign lifecycle
Campaign status transitions. validFrom / validTo mirror the campaign's
startAt / endAt window.
{
"entityId": "4f2a…-campaign-uuid",
"status": "ACTIVE",
"name": "Summer BBQ 2026",
"validFrom": "2026-06-01T00:00:00.000Z",
"validTo": "2026-08-31T23:59:59.000Z",
"timestamp": "2026-06-09T10:00:00.000Z"
}
| Field | Type | Notes |
|---|---|---|
entityId | string (uuid) | The campaign ID |
status | string | ACTIVE (CAMPAIGN_ACTIVATED) or COMPLETED (CAMPAIGN_COMPLETED) |
name | string | Campaign display name |
validFrom / validTo | string (ISO 8601) | Campaign window (startAt / endAt) |
timestamp | string (ISO 8601) | Emit time |
Transaction
{
"transactionId": "tx-1001",
"transactionCounter": 42,
"calculationLogId": "0f3a…-calclog-uuid",
"confirmedAt": "2026-06-09T10:00:00.000Z",
"totalDiscount": 12.5,
"timestamp": "2026-06-09T10:00:00.000Z"
}
| Field | Type | Notes |
|---|---|---|
transactionId | string | POS transaction identifier |
transactionCounter | number | Monotonic per-tenant confirm counter |
calculationLogId | string (uuid) | Links to the stored calculation log |
confirmedAt | string (ISO 8601) | Server confirm time |
totalDiscount | number | Total discount applied (currency units) |
timestamp | string (ISO 8601) | Emit time (equal to confirmedAt) |
LOYALTY_POINTS_AWARDED and TRANSACTION_RETURNED fire alongside
TRANSACTION_CONFIRMED on the same confirm, but each only under its own
condition — subscribe to them independently if you want to react to loyalty or
returns specifically. A plain sale that grants no points emits neither.
{
"transactionId": "tx-1001",
"customerId": "***REDACTED***",
"pointsAwarded": 150,
"calculationLogId": "0f3a…-calclog-uuid",
"actor": "pos-terminal-7",
"correlationId": "8e0a08b4-…",
"timestamp": "2026-06-09T10:00:00.000Z"
}
Fires only when the confirmed transaction granted loyalty points
(pointsAwarded > 0). customerId is pseudonymous and redacted in the
payload (***REDACTED***) — match it on your side via your own POS/customer
linkage, not from this field.
| Field | Type | Notes |
|---|---|---|
transactionId | string | POS transaction identifier |
customerId | string | Redacted (***REDACTED***) — never the raw customer ID |
pointsAwarded | number | Loyalty points granted by this transaction (always > 0) |
calculationLogId | string (uuid) | Links to the stored calculation log |
actor / correlationId | string | Envelope fields |
timestamp | string (ISO 8601) | Emit time |
{
"transactionId": "tx-1001",
"returnedAmount": -25.0,
"returnedLineCount": 1,
"calculationLogId": "0f3a…-calclog-uuid",
"confirmedAt": "2026-06-09T10:00:00.000Z",
"actor": "pos-terminal-7",
"correlationId": "8e0a08b4-…",
"timestamp": "2026-06-09T10:00:00.000Z"
}
Fires only when the confirmed basket contained return lines (negative
quantities). returnedAmount is signed (≤ 0, the sum of qty × unitPrice
over return lines); negate it if you want a positive refund magnitude. The guard
is the presence of return lines, not the amount — an all-zero-price return still
fires with returnedAmount: 0.
| Field | Type | Notes |
|---|---|---|
transactionId | string | POS transaction identifier |
returnedAmount | number | Signed total of the return lines (≤ 0, currency units) |
returnedLineCount | number | Number of return (negative-quantity) lines |
calculationLogId | string (uuid) | Links to the stored calculation log |
confirmedAt | string (ISO 8601) | Server confirm time |
actor / correlationId | string | Envelope fields |
timestamp | string (ISO 8601) | Emit time |
Budget
{
"budgetId": "7c2e…-budget-uuid",
"consumedPercent": 80.0,
"remainingPct": 20.0,
"consumedAmount": 8000.0,
"totalAmount": 10000.0,
"triggeredThresholds": [50, 80],
"exhausted": false,
"tenantId": "tenant-a",
"timestamp": "2026-06-09T10:00:00.000Z"
}
| Field | Type | Notes |
|---|---|---|
budgetId | string (uuid) | The budget that crossed a threshold |
consumedPercent | number | Percent consumed, rounded to 2 decimals |
remainingPct | number | Percent remaining, rounded to 2 decimals |
consumedAmount / totalAmount | number | Absolute consumption vs. cap (currency units) |
triggeredThresholds | number[] | Threshold percentages crossed in this check |
exhausted | boolean | true once consumedPercent ≥ 100 |
tenantId | string | Owning tenant |
timestamp | string (ISO 8601) | Emit time |
{
"budgetId": "7c2e…-budget-uuid",
"consumedAmount": 10000.0,
"totalAmount": 10000.0,
"tenantId": "tenant-a",
"timestamp": "2026-06-09T10:00:00.000Z"
}
| Field | Type | Notes |
|---|---|---|
budgetId | string (uuid) | The exhausted budget |
consumedAmount / totalAmount | number | Consumption vs. cap at exhaustion (equal) |
tenantId | string | Owning tenant |
timestamp | string (ISO 8601) | Emit time |
On 100% budget exhaustion, both BUDGET_THRESHOLD_REACHED (with
exhausted: true) and BUDGET_EXHAUSTED fire. Use BUDGET_EXHAUSTED
for exact terminal routing (no payload inspection needed); use
BUDGET_THRESHOLD_REACHED for threshold monitoring across all configured
thresholds. Existing BUDGET_THRESHOLD_REACHED subscribers are unaffected —
BUDGET_EXHAUSTED is purely additive.
Coupon
Coupon bodies repeat the canonical event name in an event field.
{
"event": "COUPON_ISSUED",
"code": "WELCOME15",
"couponTypeId": "c0de…-coupontype-uuid",
"couponTypeName": "Welcome 15%",
"customerId": "cust-9001",
"status": "CREATED",
"validFrom": "2026-06-01T00:00:00.000Z",
"validTo": "2026-12-31T23:59:59.000Z",
"timestamp": "2026-06-09T10:00:00.000Z"
}
{
"event": "COUPON_REDEEMED",
"code": "WELCOME15",
"couponCodeId": "a1b2…-couponcode-uuid",
"transactionId": "tx-1001",
"previousStatus": "RESERVED",
"tenantId": "tenant-a",
"timestamp": "2026-06-09T10:00:00.000Z"
}
{
"event": "COUPON_CANCELLED",
"code": "WELCOME15",
"couponCodeId": "a1b2…-couponcode-uuid",
"previousStatus": "RESERVED",
"reason": "Reservation cancelled by POS",
"timestamp": "2026-06-09T10:00:00.000Z"
}
{
"event": "COUPON_EXPIRED",
"code": "WELCOME15",
"couponCodeId": "a1b2…-couponcode-uuid",
"previousStatus": "ACTIVE",
"timestamp": "2026-06-09T10:00:00.000Z"
}
{
"event": "COUPON_BATCH_COMPLETED",
"batchId": "b47c…-batch-uuid",
"couponTypeId": "c0de…-coupontype-uuid",
"codesGenerated": 100,
"timestamp": "2026-06-09T10:00:00.000Z"
}
{
"event": "COUPON_POOL_LOW",
"couponTypeId": "c0de…-coupontype-uuid",
"availableCodes": 4,
"threshold": 10,
"timestamp": "2026-06-09T10:00:00.000Z"
}
| Field | Type | Notes |
|---|---|---|
event | string | Canonical event name, repeated in the body |
code | string | Coupon code |
couponCodeId | string (uuid) | Coupon-code instance ID (redeemed/cancelled) |
couponTypeId / couponTypeName | string | Coupon type (issued) |
customerId | string | Customer attribution (issued) |
status | string | CREATED on issue |
transactionId | string | Redeeming transaction (redeemed) |
previousStatus | string | State before the transition (RESERVED on cancel; the prior status — e.g. ACTIVE or CREATED — on COUPON_EXPIRED) |
reason | string | Cancellation reason (cancelled) |
batchId | string (uuid) | Generated refill batch (COUPON_BATCH_COMPLETED) |
codesGenerated | number | Number of codes the batch generated (COUPON_BATCH_COMPLETED) |
availableCodes | number | Active code count at the low-stock check (COUPON_POOL_LOW) |
threshold | number | Configured refill threshold (COUPON_POOL_LOW) |
tenantId | string | Present on the asynchronous confirm-path redemption |
validFrom / validTo | string (ISO 8601) | Validity window (issued) |
timestamp | string (ISO 8601) | Emit time |
Import jobs (via subscription)
When delivered through a subscription, import-job events carry the fan-out shape:
{
"jobId": "bulk-2026-06-09-T100000-7f3a",
"type": "MASTER_DATA_PRODUCT",
"status": "SUCCEEDED",
"rowsProcessed": 1200,
"rowsSkipped": 3,
"minorVersion": 13,
"meta": { "deliveredAt": 1781015738000, "tenantId": "tenant-a" },
"timestamp": "2026-06-09T10:00:00.000Z"
}
The per-job callback channel (see below) delivers a slightly different
shape for the same job — it includes the row-level errors[] and omits meta.
It is also versioned independently: the subscription fan-out carries
minorVersion: 13, while the per-job callback body legitimately still shows
minorVersion: 0.
Article import jobs (via subscription)
ArticleImportJobs.* is a separate event family from ImportJobs.*. It
covers the article-catalog import job (/pos/articles/import batch / the
ArticleImportJobs entity), keeping the ImportJobs.* master-data shape
stable. Subscribe with ArticleImportJobs.* (both statuses), an exact name, or
*. A job-level failure emits ArticleImportJobs.FAILED; per-item failures
that do not abort the job leave it COMPLETED (it still emits SUCCEEDED,
with rowsSkipped > 0).
{
"jobId": "a7f3…-articleimportjob-uuid",
"type": "ARTICLE",
"status": "COMPLETED",
"rowsProcessed": 1200,
"rowsSkipped": 3,
"minorVersion": 9,
"timestamp": "2026-06-09T10:00:00.000Z"
}
| Field | Type | Notes |
|---|---|---|
jobId | string (uuid) | The ArticleImportJobs record ID |
type | string | Always ARTICLE |
status | string | COMPLETED (SUCCEEDED) or FAILED |
rowsProcessed | number | Articles created + updated (0 on FAILED) |
rowsSkipped | number | Per-item failures that did not abort the job (0 on FAILED) |
minorVersion | number | Payload contract minor version (9) |
timestamp | string (ISO 8601) | Emit time |
Delivery envelope & headers
Subscription deliveries
POST /your/callback HTTP/1.1
Content-Type: application/json
X-DRE-Signature: sha256=<lowercase hex HMAC-SHA-256 of the raw body>
X-DRE-Event: PROMOTION_ACTIVATED
X-DRE-Subscription: 3f9a…-subscription-uuid
X-DRE-Timestamp: 1781015738123
X-DRE-Delivery: 7c1f2e9a-…-outbox-row-uuid
| Header | Purpose |
|---|---|
X-DRE-Signature | sha256= + HMAC-SHA-256 of the raw request body using your subscription secret. |
X-DRE-Event | Canonical event name — route on this. |
X-DRE-Subscription | Subscription ID — useful when you operate several. |
X-DRE-Timestamp | Delivery time (unix ms). Advisory only — see the note below. |
X-DRE-Delivery | Unique delivery ID — the outbox row UUID. Stable across retries of a delivery; use it to deduplicate at-least-once deliveries (see the tip below). |
X-DRE-Timestamp is not part of the signatureFor subscription deliveries the signature covers the raw body only, not the
timestamp. Treat X-DRE-Timestamp as an informational hint, not as
cryptographic replay protection. Because delivery is at-least-once, make your
receiver idempotent — deduplicate on the X-DRE-Delivery ID (below), or on a
natural key in the payload (for example transactionId, entityId +
X-DRE-Event, or coupon code).
X-DRE-DeliveryDelivery is at-least-once — a receiver can occasionally see the same
delivery twice (for example when it processed a request but the 2xx response
was lost, so DRE retried). Every delivery carries an X-DRE-Delivery header: a
UUID identifying the outbox row. It is stable across retries of that row, so
a store-and-check keeps processing idempotent:
// Store-and-check on the delivery ID (a few days' TTL is plenty).
async function handleWebhook(req, reply) {
const deliveryId = req.headers['x-dre-delivery'];
if (await seenDeliveries.has(deliveryId)) return reply.code(200); // already done
await process(req.body);
await seenDeliveries.add(deliveryId);
return reply.code(200);
}
X-DRE-Delivery identifies one delivery row, not the event: if the same
logical event is re-emitted it produces a new row with a new ID. For
event-level identity combine X-DRE-Event with a payload natural key.
Verifying a subscription signature (Node.js)
import crypto from 'crypto';
// rawBody MUST be the exact bytes received, before JSON.parse.
function verifyDreSubscription(rawBody, signatureHeader, secret) {
const expected =
'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(signatureHeader);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Per-job import callbacks
When you submit a bulk import with a callbackUrl and callbackSecret, DRE
calls back once the job reaches a terminal status.
{
"jobId": "bulk-2026-06-09-T100000-7f3a",
"type": "MASTER_DATA_PRODUCT",
"status": "SUCCEEDED",
"rowsProcessed": 1200,
"rowsSkipped": 3,
"errors": [
{ "rowIndex": 17, "rowKey": "ART-1042", "code": "VALIDATION", "message": "unknown unit" }
],
"minorVersion": 0,
"deliveredAt": 1781015738000
}
| Field | Type | Notes |
|---|---|---|
jobId | string | The import job ID |
type | string | Job type — one of PROMOTION_BULK, MASTER_DATA_PRODUCT |
status | string | SUCCEEDED or FAILED |
rowsProcessed / rowsSkipped | number | Row counts |
errors | object[] | Row-level errors: { rowIndex, rowKey, code, message } |
minorVersion | number | Payload contract minor version |
deliveredAt | number | Build time (unix ms) |
This channel uses a timestamped signature: t=<unix-seconds>,v1=<hex>,
where v1 = HMAC-SHA-256(secret, "<t>.<rawBody>"). Reject deliveries whose
timestamp falls outside a 5-minute tolerance window — this defeats replay and
clock-skew.
Per-job callbacks also carry the X-DRE-Delivery header (the outbox row UUID)
for the same at-least-once deduplication purpose described above.
import crypto from 'crypto';
function verifyDrePerJob(rawBody, signatureHeader, secret, toleranceSec = 300) {
const m = /^t=(\d+),v1=([0-9a-f]+)$/.exec(signatureHeader);
if (!m) return false;
const t = Number(m[1]);
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
const a = Buffer.from(m[2], 'hex');
const b = Buffer.from(expected, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Delivery, retries & dead-letter
Your endpoint should answer HTTP 2xx within a few seconds. Any non-2xx status, a network error or a timeout is treated as a failed attempt.
- Subscription events retry per the subscription's
backoffPolicy:EXPONENTIAL(default):2^attempt × 30s, capped at 3600s.FIXED: the subscription'sretryIntervalMs.- up to
maxAttempts(default5).
- Per-job callbacks retry on a fixed ladder: 1m → 5m → 30m → 2h → 12h (5 attempts).
After the attempts are exhausted the delivery is marked DEAD_LETTER.
Tenant operators are notified, and the row stays visible in the External
notifications app where it can be replayed manually — see section 7.4
of the user manual.
The dead-letter signal (WEBHOOK_DELIVERY_DEAD_LETTER / WEBHOOK_DEAD_LETTER)
is delivery-internal — you cannot subscribe to it as a webhook event.
Routing a dead-letter event back through subscription delivery (whose own
delivery can fail) would risk an amplification loop, so it is deliberately not
fanned out. Observe failed deliveries instead via: the operator notification on
dead-letter, the DEAD_LETTER row in the External notifications app, or the
dre_webhook_dead_letters_total Prometheus metric. Each is a deliverability bug
worth alerting on.
Delivery statuses you may see there:
| Status | Meaning |
|---|---|
PENDING | Queued or waiting for the next retry |
DELIVERED | Receiver returned 2xx |
DEAD_LETTER | All attempts failed |
SKIPPED_NO_SECRET | The subscription's signing secret was unavailable, so DRE did not send an unsigned request. Re-save the secret to recover. |
Security
- HTTPS required in production.
callbackUrlmust behttps://on the production profile (dev/test additionally accepthttp://for local mock receivers). - SSRF protection. Callback URLs that resolve to loopback (
127.0.0.0/8,::1), RFC1918 (10/8,172.16/12,192.168/16), or link-local / cloud-metadata ranges (169.254.0.0/16) are rejected — at subscribe time and re-checked at delivery time (DNS-rebinding-safe). - Secret handling. The secret is write-only: it is never returned on read after creation. Rotate by PATCHing a new value; DRE signs all subsequent deliveries with the new secret, so update your receiver at the same time or signature verification will fail and deliveries will dead-letter.