Webhooks & logs
Inspect webhook delivery across apps, re-send parked events, and review the audit trail and gateway-callback evidence from the admin panel.
Three operational logs help an operator debug deliveries and trace what happened: the webhook delivery log, the append-only audit log, and the gateway-callback evidence log. All three are session-authenticated admin surfaces gated by RBAC authorities — they are not part of the app-facing API.
These are read-and-act surfaces for operators. App developers integrating webhook consumers should start with the Webhooks guide.
All responses use the standard envelope:
{
"data": <payload | null>,
"meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:00:00Z" },
"pagination": { "page": 0, "size": 20, "totalElements": 42, "totalPages": 3 }
}The examples below show just the data payload after the first one. Admin
endpoints authenticate with a session bearer token, not an API key:
-H "Authorization: Bearer SESSION_TOKEN"Webhook delivery log
GET /api/v1/admin/webhooks lists outbox events with their delivery state across
every app the caller is scoped to. The coarse authority audit:read is checked
on the endpoint, then the result set is narrowed to the apps the operator can see
(RBAC-4) — one app never appears in another operator's view. See
app isolation.
Query parameters
Every filter is optional; a missing filter leaves that dimension unconstrained.
| Field | Type | Required | Notes |
|---|---|---|---|
page | int | No | Zero-based page index. Default 0. |
size | int | No | Page size. Default 20. |
sortBy | string | No | Sort property. Default createdAt. |
order | ASC | DESC | No | Sort direction. Default DESC. |
paginate | boolean | No | When false, returns the full result set unpaged. Default true. |
applicationId | Long | No | Restrict to one app. |
mode | TEST | LIVE | No | Enum name (see below). |
type | WebhookEventType name | No | Enum name such as PAYMENT_SUCCEEDED — not the dotted wire form. |
deliveryStatus | PENDING | DELIVERED | FAILED | No | Enum name. |
See pagination & filtering for the
shared page / size / sortBy / order / paginate conventions.
Filter by the enum NAME, not the wire type. The type, mode, and
deliveryStatus filters are parsed with Enum.valueOf(...), so they expect the
Java constant name. Filtering by type=SUBSCRIPTION_RENEWED works;
type=subscription.renewed (the dotted form your consumer receives) fails to
parse and returns a 400. The response field type, however, is rendered as the
dotted wire name — so you filter by PAYMENT_SUCCEEDED but read back
payment.succeeded.
The full set of catalogue names you can pass as type:
PAYMENT_SUCCEEDED PAYMENT_FAILED PAYMENT_EXPIRED
REFUND_PENDING REFUND_SUCCEEDED REFUND_FAILED
INVOICE_ISSUED INVOICE_PARTIALLY_PAID INVOICE_PAID INVOICE_VOIDED
SUBSCRIPTION_CREATED SUBSCRIPTION_ACTIVATED SUBSCRIPTION_TRIAL_WILL_END
SUBSCRIPTION_RENEWED SUBSCRIPTION_PAYMENT_SUCCEEDED SUBSCRIPTION_PAYMENT_FAILED
SUBSCRIPTION_PAST_DUE SUBSCRIPTION_CANCELED SUBSCRIPTION_PAUSED
SUBSCRIPTION_RESUMED SUBSCRIPTION_ENTITLEMENTS_UPDATEDWebhookEventDto (response row)
Each delivery-log row is a WebhookEventDto. Null fields are omitted from the JSON.
| Field | Type | Required | Notes |
|---|---|---|---|
id | Long | Yes | Outbox event id. This is also the X-Webhook-Id header your endpoint received — use it to correlate. |
applicationId | Long | Yes | The owning app. |
applicationName | string | No | Resolved app display name. Omitted when the app can no longer be resolved (fall back to applicationId). |
mode | string | Yes | TEST or LIVE. |
type | string | Yes | The dotted wire name carried in the delivered envelope (e.g. subscription.renewed). |
schemaVersion | int | Yes | Payload schema version. |
payload | string | Yes | The event's typed data object as a JSON string (the app's own data). No signing secret ever appears here. |
deliveryStatus | string | Yes | PENDING, DELIVERED, or FAILED. |
attempts | int | Yes | Delivery attempts made so far. |
lastResponseStatus | Integer | No | HTTP status of the most recent attempt. null (omitted) when the endpoint was unreachable. |
lastError | string | No | Short diagnostic for the most recent failed attempt. Omitted once delivered. |
nextAttemptAt | datetime | No | When the row is next eligible for an attempt. Omitted when not scheduled (delivered, parked, or due now). |
lastAttemptAt | datetime | No | When the most recent attempt ran. Omitted until the first attempt. |
deliveredAt | datetime | No | When a 2xx acknowledgement was received. Omitted until delivered. |
createdAt | datetime | Yes | Event-creation time — authoritative for ordering and consumer dedupe. |
updatedAt | datetime | Yes | Last update to the row. |
The mode filter is an enum name, but unlike the dashboard analytics endpoints
it is optional here — there is no required mode query parameter on the
delivery log. The mode shown on each row is the mode of the producing record; it
is never set by a caller. See modes.
Example — failed subscription renewals
curl "http://localhost:8080/api/v1/admin/webhooks?type=SUBSCRIPTION_RENEWED&deliveryStatus=FAILED&size=20" \
-H "Authorization: Bearer SESSION_TOKEN"{
"data": [
{
"id": 90817,
"applicationId": 42,
"applicationName": "Acme Storefront",
"mode": "LIVE",
"type": "subscription.renewed",
"schemaVersion": 1,
"payload": "{\"subscription_id\":\"sub_8f3a\",\"amount\":150000,\"currency\":\"BDT\"}",
"deliveryStatus": "FAILED",
"attempts": 6,
"lastResponseStatus": 503,
"lastError": "503 Service Unavailable",
"lastAttemptAt": "2026-06-30T09:41:12",
"createdAt": "2026-06-30T08:55:00",
"updatedAt": "2026-06-30T09:41:12"
}
],
"meta": { "success": true, "message": null, "errorCode": null, "timestamp": "2026-06-30T12:00:00Z" },
"pagination": { "page": 0, "size": 20, "totalElements": 1, "totalPages": 1 }
}Note the row above has deliveryStatus: FAILED and attempts: 6, and no
nextAttemptAt or deliveredAt — it is parked. The amount of 150000 in
the payload is paisa: 1,500.00 BDT. See money.
Delivery status & the retry lifecycle
A captured event starts PENDING and is driven by the background delivery loop.
The status field has exactly three values:
| Status | Meaning |
|---|---|
PENDING | Captured and awaiting a delivery attempt — either never attempted yet, or failed-with-budget and scheduled to retry at nextAttemptAt. |
DELIVERED | The consumer endpoint returned 2xx. Terminal; deliveredAt is set and nextAttemptAt/lastError are cleared. |
FAILED | The retry budget was exhausted without a 2xx. The event is parked — it is never retried automatically again, only via a manual re-send. |
Two conditions park on the first attempt instead of consuming the retry budget. Both are things only an operator or the merchant can fix, so re-deriving the same answer thirteen more times over six hours would waste claim capacity and — worse — delay the moment anyone notices, because the row stays non-terminal until the budget drains:
lastError | Meaning | Fix |
|---|---|---|
no webhook_url configured for app N | The app has no endpoint at all. | Set a webhook URL in app settings. |
LIVE_REQUIRES_HTTPS | A LIVE event whose configured endpoint is not https. | Change the app's webhook URL to https. See Webhook URL requirements. |
LIVE_REQUIRES_HTTPS is deliberately not SCHEME_BLOCKED. SCHEME_BLOCKED means
the deployment's scheme allowlist refused the URL and is changed by configuration.
LIVE_REQUIRES_HTTPS is unconditional and no configuration overrides it. They used to
share one code, which put a message in front of operators that never mentioned TLS and
pointed them at an allowlist that was not the problem.
The delivery loop is configured in app.webhook.delivery.*. The values that govern
when a row parks:
| Tunable | Default | Meaning |
|---|---|---|
maxAttempts | 14 | Total attempts before the event is parked FAILED. |
initialBackoff | 10s | Wait before the first retry; doubles each subsequent retry. |
maxBackoff | 1h | Upper bound on the exponential backoff. |
jitterRatio | 0.2 | ±20% randomisation applied to each computed backoff. |
batchSize | 250 | How many due events one sweep claims and delivers. |
connectTimeout | 3s | Connect timeout for the delivery POST. |
responseTimeout | 8s | Response timeout for the delivery POST. |
requestTimeout | 15s | Absolute ceiling on one delivery attempt. |
Backoff before the n-th retry (0-based) is min(maxBackoff, initialBackoff · 2ⁿ),
then jittered by ±jitterRatio, giving a retry horizon of roughly 6 hours across
all 14 attempts. After maxAttempts failed attempts the event parks. The two
immediate-park conditions above bypass this schedule entirely.
Webhook URL requirements
An application has exactly one webhook_url, shared by TEST and LIVE. The
mode of a delivery is derived from the credential that authenticated the request that
produced the event — it is never derived from the URL, and there is no per-mode
endpoint to configure.
LIVE events are never transmitted over an unencrypted connection. The delivery
signature proves the payload was not altered; it does not keep it private, and a
plaintext LIVE payload exposes amounts, customer references and event types to
anyone on the network path.
The shared-URL consequence, which surprises people. Because one URL serves both
modes, an app with an http:// endpoint receives TEST webhooks completely normally
while every LIVE webhook parks. From the merchant's side this looks like nothing at
all — their integration "works" in test and produces silence in production. That
asymmetry is why the URL is now rejected at write time, where someone is present to
read the reason, rather than only at delivery time.
Enforcement
| Where | Behaviour |
|---|---|
App registration (POST /api/v1/admin/apps) | A non-https webhookUrl is rejected with 400 and error code WEBHOOK_URL_REQUIRES_TLS. No credentials are issued. |
| App settings update | Same rejection, evaluated against the merged post-update state. |
Delivery (LIVE) | Refused before any connection is opened; the event parks with lastError: "LIVE_REQUIRES_HTTPS". |
Delivery (TEST) | Governed by the scheme allowlist only. |
Registration provisions an ACTIVE LIVE credential for every app, with no
intermediate gate — so every app is LIVE-capable from the moment it exists, and
"this URL will carry LIVE traffic" is true of every URL the system stores. That is
why the write-time rule is simply "must be https" rather than a per-mode rule: with
one shared column and universal LIVE capability, the two are the same rule stated at
different moments.
Relaxing it (self-hosted / sandbox)
Plaintext endpoints are permitted by widening the allowlist the transport already
uses — app.webhook.delivery.http.allowed-schemes, env WEBHOOK_DELIVERY_SCHEMES:
WEBHOOK_DELIVERY_SCHEMES=https,httpThe shipped default is https alone. This is deliberately the same key the
delivery transport reads rather than a second switch, so the two can never be set to
disagree.
Widening this permits storing an http:// URL and delivering TEST events to
it. It does not make LIVE delivery over plaintext work — nothing does. On such a
deployment the split brain is re-enabled rather than removed: TEST delivers, LIVE
parks immediately with LIVE_REQUIRES_HTTPS. Use it for sandbox and CI stacks where no
app is live, never in production.
A URL stored before this rule existed, or written directly into the column, is not retroactively rejected — it is caught at delivery time by the runtime check, which remains in place as the last line rather than the only one.
Re-send a parked event
POST /api/v1/admin/webhooks/{id}/resend re-queues one parked, failed, or still-pending
event for re-delivery and returns its re-queued state.
This action is gated by app:manage. The authority is checked coarsely on the
endpoint, then re-checked app-scoped against the event's owning app inside the
use case (RBAC-4). It is a sensitive action and additionally requires a recent
re-authentication:
- Unknown event id →
404(RESOURCE_NOT_FOUND). - Operator lacks
app:managefor that event's app →403(FORBIDDEN). - No recent re-authentication →
403(FORBIDDEN). - The event is being delivered right now →
409. Retry a few seconds later.
Re-queue
The row is set back to PENDING, due now, with attempts reset to 0 and
deliveredAt cleared. A re-send buys a full fresh retry budget — an operator asking
to re-send a parked event means "try properly again", not "try once more".
The diagnostic fields — lastError, lastResponseStatus, lastAttemptAt — are
deliberately not cleared. They still describe the attempt that failed before the
re-send, which is what lets you look at a re-queued row and still see what you were
re-sending it for. The next attempt overwrites them.
Audit
A WEBHOOK_RESENT entry is written to the append-only audit log (targetType: WEBHOOK_EVENT, targetId = the event id), recording the operator, the event type,
the app, and the attempt count.
Deliver on the next sweep
Delivery is enqueued, not inline — no HTTP call is made inside the admin request,
so the endpoint returns immediately and a slow consumer can never hold the request
open. The returned WebhookEventDto therefore shows the re-queued state
(PENDING, attempts: 0), not a delivery outcome. Refresh the log to see the result.
A re-send keeps the same X-Webhook-Id (the event id never changes), so a
well-behaved consumer that
dedupes on the id treats the
replay as the same event and does not double-process it.
Example
curl -X POST "http://localhost:8080/api/v1/admin/webhooks/90817/resend" \
-H "Authorization: Bearer SESSION_TOKEN"A successful re-send returns the re-queued row — deliveryStatus back to
PENDING, attempts reset to 0, deliveredAt cleared. This is the state before the
next sweep picks it up, not a delivery outcome:
{
"id": 90817,
"applicationId": 42,
"applicationName": "Acme Storefront",
"mode": "LIVE",
"type": "subscription.renewed",
"schemaVersion": 1,
"payload": "{\"subscription_id\":\"sub_8f3a\",\"amount\":150000,\"currency\":\"BDT\"}",
"deliveryStatus": "PENDING",
"attempts": 0,
"lastResponseStatus": 503,
"lastError": "503 Service Unavailable",
"lastAttemptAt": "2026-06-30T09:41:12",
"createdAt": "2026-06-30T08:55:00",
"updatedAt": "2026-06-30T12:01:30"
}Two things about that response are easy to misread.
lastError, lastResponseStatus and lastAttemptAt are carried over, not cleared.
They describe the attempt that failed before the re-send. A re-queued row that shows
503 has not just failed again — it has not been tried yet. Only attempts,
deliveryStatus and deliveredAt are reset.
Null fields are omitted, never emitted as null. The response uses
NON_NULL serialization, so deliveredAt is absent above rather than present-and-null.
Do not write a client that waits for an explicit null.
Refresh the delivery log to see the outcome. If the consumer is still down the row
stays PENDING with a new nextAttemptAt and works through its fresh 14-attempt
budget, then parks FAILED again with lastError and lastResponseStatus
describing the latest failure.
Audit log
GET /api/v1/admin/audit-logs (requires audit:read) is the append-only trail
of sensitive actions — credential rotation/revocation, gateway overrides, user and
role management, refund approvals, and webhook re-sends (WEBHOOK_RESENT). Filter by
actorType, actorId, action, targetType, and targetId, with the same
page / size / sortBy / order / paginate conventions.
actorType is the enum name — USER (a human admin), APP (a client
application acting via its API key), or SYSTEM (an automated action).
Each row is an AuditLogDto:
| Field | Type | Required | Notes |
|---|---|---|---|
id | Long | Yes | Entry id. |
actorType | string | Yes | USER, APP, or SYSTEM. |
actorId | Long | No | Admin-user id or application id, per actorType. |
actorLabel | string | No | Resolved human-readable actor label. |
action | string | Yes | The audited action, e.g. WEBHOOK_RESENT. |
targetType | string | No | The kind of entity acted on, e.g. WEBHOOK_EVENT. |
targetId | string | No | The target entity id. |
metadata | string | No | Free-form context string captured at the time of the action. |
createdAt | datetime | Yes | When the action occurred. |
The audit log can be read but never edited or deleted. It is the record of who did what, when — treat a missing entry as significant.
Gateway-callback evidence
GET /api/v1/admin/gateway-callbacks (requires audit:read) records every inbound
gateway callback (IPN) the service received, with the decision it applied — useful
when a payment's state and the gateway appear to disagree. Filter by mode,
outcome, reference, matchedPaymentId, gateway, and signatureValid.
Callback evidence is forensic data with no single owning app (an unmatched
callback matched no payment), so — like the audit trail — it is not
app-scope-narrowed; the audit:read authority is the only gate.
Each row is a GatewayCallbackLogDto:
| Field | Type | Required | Notes |
|---|---|---|---|
id | Long | Yes | Evidence row id. |
gateway | string | Yes | The gateway that sent the callback (e.g. sslcommerz). |
mode | string | Yes | TEST or LIVE. |
reference | string | No | The gateway's transaction reference. |
matchedPaymentId | Long | No | The payment the callback was matched to, if any. |
signatureValid | boolean | Yes | Whether the callback's signature verified. |
outcome | string | Yes | The decision applied (see below). |
note | string | No | Short handler note. |
rawPayload | string | No | The callback's fields exactly as received, for forensic inspection (gateway POST data, no card/PAN material). |
createdAt | datetime | Yes | When the callback was processed. |
The outcome enum name explains what the handler did:
| Outcome | Meaning |
|---|---|
SETTLED | Server-confirmed success matching amount/currency → payment marked succeeded. |
FAILED | Gateway reported the attempt failed → payment marked failed. |
CANCELLED | Customer abandoned the hosted checkout → payment marked cancelled. |
DUPLICATE | Verified, but the payment was already terminal — acknowledged, no change. |
MISMATCH | Confirmed success whose amount/currency did not match — flagged for review, payment left unpaid. |
SIGNATURE_INVALID | Signature did not verify — rejected, no state change. |
UNVERIFIED | Could not be authoritatively confirmed server-side — payment left unchanged. |
UNMATCHED | No payment matched the callback's reference — recorded and ignored. |
An outcome of SIGNATURE_INVALID or DUPLICATE here explains why a callback did
not move a payment, without you having to dig into the gateway's own dashboard. See
reconciliation for matching settled gateway records
against the ledger.
Related
Webhooks guide
Build and verify a webhook consumer: signatures, dedupe, and the event catalogue.
Transactions
The cross-app payment list these callbacks settle against.
Reconciliation
Match gateway settlement against the double-entry ledger.
RBAC
The audit:read and app:manage authorities these surfaces gate on.