Zum Hauptinhalt springen

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.

ChannelSourceHow you opt inSignature scheme
Subscription eventsPromotion / Transaction / Budget / Coupon lifecycle + import-job fan-outCreate a WebhookSubscriptions row (admin UI or POST /api/v1/WebhookSubscriptions)X-DRE-Signature: sha256=<hex>
Per-job callbackA single bulk-import jobSet callbackUrl (+ callbackSecret) on the ImportJobs requestX-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).

Delivery is asynchronous

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_URL set). 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:

FieldRequiredDescription
callbackUrlyesHTTPS endpoint DRE POSTs to. Must be a public address — loopback, RFC1918 and link-local/metadata IPs are rejected (see Security).
secretyesShared secret used to compute the sha256= HMAC. Min 16 characters. Write-only — never returned after creation; PATCH a new value to rotate.
eventFilteryesWhich events this subscription receives (default *). See the grammar below.
activenoToggle delivery on/off without deleting (default true).
maxAttemptsnoPer-subscription retry cap (default 5).
backoffPolicynoEXPONENTIAL (default) or FIXED.
retryIntervalMsnoInterval for FIXED backoff, in milliseconds.

Event-filter grammar

PatternMatches
*Every event your tenant emits
<entity>.<status>One exact event, e.g. ImportJobs.SUCCEEDED
<entity>.*Every status of an entity, e.g. ImportJobs.*
Lifecycle event names contain no dot

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 whenReachable via
PROMOTION_APPROVEDA promotion is approved (PENDING_APPROVAL → APPROVED)* or exact
PROMOTION_REJECTEDA promotion is rejected* or exact
PROMOTION_ACTIVATEDA promotion is activated* or exact
PROMOTION_DEACTIVATEDA promotion is deactivated* or exact
PROMOTION_EXPIREDA promotion is auto-expired by the scheduler (validTo elapsed, ACTIVE → EXPIRED)* or exact
TRANSACTION_CONFIRMEDA POS transaction is confirmed (/pos/v2/confirm success)* or exact
BUDGET_THRESHOLD_REACHEDA budget crosses a configured threshold or is exhausted* or exact
COUPON_ISSUEDA coupon is issued* or exact
COUPON_REDEEMEDA coupon is redeemed* or exact
COUPON_CANCELLEDA coupon reservation is cancelled* or exact
COUPON_EXPIREDA single coupon code reaches the terminal EXPIRED state (auto-expire past validTo, or expire-during-activation)* or exact
PROMOTION_SUBMITTEDA promotion is submitted for approval (DRAFT → PENDING_APPROVAL)* or exact
PROMOTION_ARCHIVEDA promotion is archived (status changed to ARCHIVED)* or exact
CAMPAIGN_ACTIVATEDA campaign transitions to ACTIVE (draft activation or status change)* or exact
CAMPAIGN_COMPLETEDA campaign transitions to COMPLETED* or exact
BUDGET_EXHAUSTEDA budget reaches 100% consumption (distinct terminal event; see note under Budget)* or exact
COUPON_BATCH_COMPLETEDA coupon auto-refill batch finishes generating codes* or exact
COUPON_POOL_LOWThe active code count falls below the refill threshold (proactive low-stock signal)* or exact
ImportJobs.SUCCEEDEDA bulk-import job completes successfully*, ImportJobs.*, or exact
ImportJobs.FAILEDA bulk-import job fails*, ImportJobs.*, or exact
ArticleImportJobs.SUCCEEDEDAn article-catalog import job completes successfully*, ArticleImportJobs.*, or exact
ArticleImportJobs.FAILEDAn article-catalog import job fails at job level*, ArticleImportJobs.*, or exact
LOYALTY_POINTS_AWARDEDA confirmed POS transaction granted loyalty points (fires alongside TRANSACTION_CONFIRMED, only when points > 0)* or exact
TRANSACTION_RETURNEDA confirmed POS basket contained return lines (fires alongside TRANSACTION_CONFIRMED, only when the basket has return lines)* or exact
PROMOTION_UPDATEDA promotion edit changed a material field (discountValue, validFrom, validTo, exclusivityLevel, ExclusionGroup_ID) — cosmetic edits (name/description) do not fire it* or exact
Events that are NOT delivered

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:

FieldTypeNotes
actorstringWho 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).
correlationIdstringThe 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.
resourceVersionstringThe 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

PROMOTION_APPROVED
{
"entityId": "9b1c…-promotion-uuid",
"target": "APPROVED",
"approver": "jane.doe",
"timestamp": "2026-06-09T10:00:00.000Z"
}
PROMOTION_REJECTED
{
"entityId": "9b1c…-promotion-uuid",
"approver": "jane.doe",
"comment": "Discount too aggressive for Q3",
"timestamp": "2026-06-09T10:00:00.000Z"
}
PROMOTION_ACTIVATED / PROMOTION_DEACTIVATED
{
"entityId": "9b1c…-promotion-uuid",
"timestamp": "2026-06-09T10:00:00.000Z"
}
PROMOTION_EXPIRED
{
"entityId": "9b1c…-promotion-uuid",
"reason": "VALIDITY_ELAPSED",
"deactivatedAt": "2026-06-09T10:00:00.000Z",
"minorVersion": 9,
"timestamp": "2026-06-09T10:00:00.000Z"
}
PROMOTION_SUBMITTED
{
"entityId": "9b1c…-promotion-uuid",
"author": "jane.doe",
"timestamp": "2026-06-09T10:00:00.000Z"
}
PROMOTION_ARCHIVED
{
"entityId": "9b1c…-promotion-uuid",
"timestamp": "2026-06-09T10:00:00.000Z"
}
PROMOTION_UPDATED
{
"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.

FieldTypeNotes
entityIdstring (uuid)The promotion ID
targetstringStatus after approval (APPROVED); PROMOTION_APPROVED only
approverstringUser who approved/rejected; approve + reject only
commentstringRejection reason; PROMOTION_REJECTED only
reasonstringWhy it expired (VALIDITY_ELAPSED); PROMOTION_EXPIRED only
deactivatedAtstring (ISO 8601)When the scheduler expired it; PROMOTION_EXPIRED only
authorstringUser who submitted the promotion; PROMOTION_SUBMITTED only
changedFieldsstring[]Material fields that changed; PROMOTION_UPDATED only. One or more of discountValue, validFrom, validTo, exclusivityLevel, ExclusionGroup_ID
actor / correlationId / resourceVersionstringEnvelope fields — present where available
timestampstring (ISO 8601)Emit time

Campaign lifecycle

Campaign status transitions. validFrom / validTo mirror the campaign's startAt / endAt window.

CAMPAIGN_ACTIVATED / CAMPAIGN_COMPLETED
{
"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"
}
FieldTypeNotes
entityIdstring (uuid)The campaign ID
statusstringACTIVE (CAMPAIGN_ACTIVATED) or COMPLETED (CAMPAIGN_COMPLETED)
namestringCampaign display name
validFrom / validTostring (ISO 8601)Campaign window (startAt / endAt)
timestampstring (ISO 8601)Emit time

Transaction

TRANSACTION_CONFIRMED
{
"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"
}
FieldTypeNotes
transactionIdstringPOS transaction identifier
transactionCounternumberMonotonic per-tenant confirm counter
calculationLogIdstring (uuid)Links to the stored calculation log
confirmedAtstring (ISO 8601)Server confirm time
totalDiscountnumberTotal discount applied (currency units)
timestampstring (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.

LOYALTY_POINTS_AWARDED
{
"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.

FieldTypeNotes
transactionIdstringPOS transaction identifier
customerIdstringRedacted (***REDACTED***) — never the raw customer ID
pointsAwardednumberLoyalty points granted by this transaction (always > 0)
calculationLogIdstring (uuid)Links to the stored calculation log
actor / correlationIdstringEnvelope fields
timestampstring (ISO 8601)Emit time
TRANSACTION_RETURNED
{
"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.

FieldTypeNotes
transactionIdstringPOS transaction identifier
returnedAmountnumberSigned total of the return lines (≤ 0, currency units)
returnedLineCountnumberNumber of return (negative-quantity) lines
calculationLogIdstring (uuid)Links to the stored calculation log
confirmedAtstring (ISO 8601)Server confirm time
actor / correlationIdstringEnvelope fields
timestampstring (ISO 8601)Emit time

Budget

BUDGET_THRESHOLD_REACHED
{
"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"
}
FieldTypeNotes
budgetIdstring (uuid)The budget that crossed a threshold
consumedPercentnumberPercent consumed, rounded to 2 decimals
remainingPctnumberPercent remaining, rounded to 2 decimals
consumedAmount / totalAmountnumberAbsolute consumption vs. cap (currency units)
triggeredThresholdsnumber[]Threshold percentages crossed in this check
exhaustedbooleantrue once consumedPercent ≥ 100
tenantIdstringOwning tenant
timestampstring (ISO 8601)Emit time
BUDGET_EXHAUSTED
{
"budgetId": "7c2e…-budget-uuid",
"consumedAmount": 10000.0,
"totalAmount": 10000.0,
"tenantId": "tenant-a",
"timestamp": "2026-06-09T10:00:00.000Z"
}
FieldTypeNotes
budgetIdstring (uuid)The exhausted budget
consumedAmount / totalAmountnumberConsumption vs. cap at exhaustion (equal)
tenantIdstringOwning tenant
timestampstring (ISO 8601)Emit time
BUDGET_EXHAUSTED vs. BUDGET_THRESHOLD_REACHED

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.

COUPON_ISSUED
{
"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"
}
COUPON_REDEEMED
{
"event": "COUPON_REDEEMED",
"code": "WELCOME15",
"couponCodeId": "a1b2…-couponcode-uuid",
"transactionId": "tx-1001",
"previousStatus": "RESERVED",
"tenantId": "tenant-a",
"timestamp": "2026-06-09T10:00:00.000Z"
}
COUPON_CANCELLED
{
"event": "COUPON_CANCELLED",
"code": "WELCOME15",
"couponCodeId": "a1b2…-couponcode-uuid",
"previousStatus": "RESERVED",
"reason": "Reservation cancelled by POS",
"timestamp": "2026-06-09T10:00:00.000Z"
}
COUPON_EXPIRED
{
"event": "COUPON_EXPIRED",
"code": "WELCOME15",
"couponCodeId": "a1b2…-couponcode-uuid",
"previousStatus": "ACTIVE",
"timestamp": "2026-06-09T10:00:00.000Z"
}
COUPON_BATCH_COMPLETED
{
"event": "COUPON_BATCH_COMPLETED",
"batchId": "b47c…-batch-uuid",
"couponTypeId": "c0de…-coupontype-uuid",
"codesGenerated": 100,
"timestamp": "2026-06-09T10:00:00.000Z"
}
COUPON_POOL_LOW
{
"event": "COUPON_POOL_LOW",
"couponTypeId": "c0de…-coupontype-uuid",
"availableCodes": 4,
"threshold": 10,
"timestamp": "2026-06-09T10:00:00.000Z"
}
FieldTypeNotes
eventstringCanonical event name, repeated in the body
codestringCoupon code
couponCodeIdstring (uuid)Coupon-code instance ID (redeemed/cancelled)
couponTypeId / couponTypeNamestringCoupon type (issued)
customerIdstringCustomer attribution (issued)
statusstringCREATED on issue
transactionIdstringRedeeming transaction (redeemed)
previousStatusstringState before the transition (RESERVED on cancel; the prior status — e.g. ACTIVE or CREATED — on COUPON_EXPIRED)
reasonstringCancellation reason (cancelled)
batchIdstring (uuid)Generated refill batch (COUPON_BATCH_COMPLETED)
codesGeneratednumberNumber of codes the batch generated (COUPON_BATCH_COMPLETED)
availableCodesnumberActive code count at the low-stock check (COUPON_POOL_LOW)
thresholdnumberConfigured refill threshold (COUPON_POOL_LOW)
tenantIdstringPresent on the asynchronous confirm-path redemption
validFrom / validTostring (ISO 8601)Validity window (issued)
timestampstring (ISO 8601)Emit time

Import jobs (via subscription)

When delivered through a subscription, import-job events carry the fan-out shape:

ImportJobs.SUCCEEDED / ImportJobs.FAILED (subscription)
{
"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).

ArticleImportJobs.SUCCEEDED / ArticleImportJobs.FAILED (subscription)
{
"jobId": "a7f3…-articleimportjob-uuid",
"type": "ARTICLE",
"status": "COMPLETED",
"rowsProcessed": 1200,
"rowsSkipped": 3,
"minorVersion": 9,
"timestamp": "2026-06-09T10:00:00.000Z"
}
FieldTypeNotes
jobIdstring (uuid)The ArticleImportJobs record ID
typestringAlways ARTICLE
statusstringCOMPLETED (SUCCEEDED) or FAILED
rowsProcessednumberArticles created + updated (0 on FAILED)
rowsSkippednumberPer-item failures that did not abort the job (0 on FAILED)
minorVersionnumberPayload contract minor version (9)
timestampstring (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
HeaderPurpose
X-DRE-Signaturesha256= + HMAC-SHA-256 of the raw request body using your subscription secret.
X-DRE-EventCanonical event name — route on this.
X-DRE-SubscriptionSubscription ID — useful when you operate several.
X-DRE-TimestampDelivery time (unix ms). Advisory only — see the note below.
X-DRE-DeliveryUnique delivery ID — the outbox row UUID. Stable across retries of a delivery; use it to deduplicate at-least-once deliveries (see the tip below).
vorsicht
X-DRE-Timestamp is not part of the signature

For 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).

tipp
Deduplicating with X-DRE-Delivery

Delivery 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.

ImportJobs callback body
{
"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
}
FieldTypeNotes
jobIdstringThe import job ID
typestringJob type — one of PROMOTION_BULK, MASTER_DATA_PRODUCT
statusstringSUCCEEDED or FAILED
rowsProcessed / rowsSkippednumberRow counts
errorsobject[]Row-level errors: { rowIndex, rowKey, code, message }
minorVersionnumberPayload contract minor version
deliveredAtnumberBuild 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.

Verifying a per-job callback signature (Node.js)
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's retryIntervalMs.
    • up to maxAttempts (default 5).
  • 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.

Dead-letter is observed, not subscribed

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:

StatusMeaning
PENDINGQueued or waiting for the next retry
DELIVEREDReceiver returned 2xx
DEAD_LETTERAll attempts failed
SKIPPED_NO_SECRETThe 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. callbackUrl must be https:// on the production profile (dev/test additionally accept http:// 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.