OI Payments Docs
Admin dashboard

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.

FieldTypeRequiredNotes
pageintNoZero-based page index. Default 0.
sizeintNoPage size. Default 20.
sortBystringNoSort property. Default createdAt.
orderASC | DESCNoSort direction. Default DESC.
paginatebooleanNoWhen false, returns the full result set unpaged. Default true.
applicationIdLongNoRestrict to one app.
modeTEST | LIVENoEnum name (see below).
typeWebhookEventType nameNoEnum name such as PAYMENT_SUCCEEDEDnot the dotted wire form.
deliveryStatusPENDING | DELIVERED | FAILEDNoEnum 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_UPDATED

WebhookEventDto (response row)

Each delivery-log row is a WebhookEventDto. Null fields are omitted from the JSON.

FieldTypeRequiredNotes
idLongYesOutbox event id. This is also the X-Webhook-Id header your endpoint received — use it to correlate.
applicationIdLongYesThe owning app.
applicationNamestringNoResolved app display name. Omitted when the app can no longer be resolved (fall back to applicationId).
modestringYesTEST or LIVE.
typestringYesThe dotted wire name carried in the delivered envelope (e.g. subscription.renewed).
schemaVersionintYesPayload schema version.
payloadstringYesThe event's typed data object as a JSON string (the app's own data). No signing secret ever appears here.
deliveryStatusstringYesPENDING, DELIVERED, or FAILED.
attemptsintYesDelivery attempts made so far.
lastResponseStatusIntegerNoHTTP status of the most recent attempt. null (omitted) when the endpoint was unreachable.
lastErrorstringNoShort diagnostic for the most recent failed attempt. Omitted once delivered.
nextAttemptAtdatetimeNoWhen the row is next eligible for an attempt. Omitted when not scheduled (delivered, parked, or due now).
lastAttemptAtdatetimeNoWhen the most recent attempt ran. Omitted until the first attempt.
deliveredAtdatetimeNoWhen a 2xx acknowledgement was received. Omitted until delivered.
createdAtdatetimeYesEvent-creation time — authoritative for ordering and consumer dedupe.
updatedAtdatetimeYesLast 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:

StatusMeaning
PENDINGCaptured and awaiting a delivery attempt — either never attempted yet, or failed-with-budget and scheduled to retry at nextAttemptAt.
DELIVEREDThe consumer endpoint returned 2xx. Terminal; deliveredAt is set and nextAttemptAt/lastError are cleared.
FAILEDThe 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:

lastErrorMeaningFix
no webhook_url configured for app NThe app has no endpoint at all.Set a webhook URL in app settings.
LIVE_REQUIRES_HTTPSA 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:

TunableDefaultMeaning
maxAttempts14Total attempts before the event is parked FAILED.
initialBackoff10sWait before the first retry; doubles each subsequent retry.
maxBackoff1hUpper bound on the exponential backoff.
jitterRatio0.2±20% randomisation applied to each computed backoff.
batchSize250How many due events one sweep claims and delivers.
connectTimeout3sConnect timeout for the delivery POST.
responseTimeout8sResponse timeout for the delivery POST.
requestTimeout15sAbsolute 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

WhereBehaviour
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 updateSame 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,http

The 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:manage for that event's app403 (FORBIDDEN).
  • No recent re-authentication → 403 (FORBIDDEN).
  • The event is being delivered right now409. 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 nameUSER (a human admin), APP (a client application acting via its API key), or SYSTEM (an automated action).

Each row is an AuditLogDto:

FieldTypeRequiredNotes
idLongYesEntry id.
actorTypestringYesUSER, APP, or SYSTEM.
actorIdLongNoAdmin-user id or application id, per actorType.
actorLabelstringNoResolved human-readable actor label.
actionstringYesThe audited action, e.g. WEBHOOK_RESENT.
targetTypestringNoThe kind of entity acted on, e.g. WEBHOOK_EVENT.
targetIdstringNoThe target entity id.
metadatastringNoFree-form context string captured at the time of the action.
createdAtdatetimeYesWhen 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:

FieldTypeRequiredNotes
idLongYesEvidence row id.
gatewaystringYesThe gateway that sent the callback (e.g. sslcommerz).
modestringYesTEST or LIVE.
referencestringNoThe gateway's transaction reference.
matchedPaymentIdLongNoThe payment the callback was matched to, if any.
signatureValidbooleanYesWhether the callback's signature verified.
outcomestringYesThe decision applied (see below).
notestringNoShort handler note.
rawPayloadstringNoThe callback's fields exactly as received, for forensic inspection (gateway POST data, no card/PAN material).
createdAtdatetimeYesWhen the callback was processed.

The outcome enum name explains what the handler did:

OutcomeMeaning
SETTLEDServer-confirmed success matching amount/currency → payment marked succeeded.
FAILEDGateway reported the attempt failed → payment marked failed.
CANCELLEDCustomer abandoned the hosted checkout → payment marked cancelled.
DUPLICATEVerified, but the payment was already terminal — acknowledged, no change.
MISMATCHConfirmed success whose amount/currency did not match — flagged for review, payment left unpaid.
SIGNATURE_INVALIDSignature did not verify — rejected, no state change.
UNVERIFIEDCould not be authoritatively confirmed server-side — payment left unchanged.
UNMATCHEDNo 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.

On this page