Webhooks -- Overview
Webhooks allow your application to receive real-time notifications about events on the Owem Pay platform. When an event occurs, Owem Pay sends an HTTP POST to the registered URL.
How It Works
- Register a webhook URL in your account
- When an event occurs (e.g., PIX received), Owem Pay sends an HTTP POST to your URL
- Your application processes the notification and responds with
2xxstatus (200, 201, or 204)
Available Events
Owem Pay delivers only PIX-related events. Other products (boleto, accounts, STA, non-PIX transfers) are out of scope. Any attempt to subscribe to events outside the table below is rejected with events: contains invalid events: ....
| Event | Status body | Description | Dispatch |
|---|---|---|---|
pix.charge.created | created | QR code generated or cash-in initiated | Active |
pix.charge.paid | paid | PIX received and settled | Active |
pix.charge.expired | expired | QR code expired without payment (checked every 5 min by worker) | Active |
pix.charge.cancelled | cancelled | QR code cancelled before payment | Registered, not yet dispatched |
pix.payout.queued | queued | PIX send queued by rate limit (per-merchant ClientLimiter or BACEN DICT bucket). Automatic retry every ~3s, max TTL 2h | Active |
pix.payout.processing | processing | PIX sent, awaiting BACEN confirmation | Active |
pix.payout.confirmed | settled | PIX sent and confirmed (terminal) | Active |
pix.payout.failed | rejected | PIX send rejected by SPI (terminal) | Active |
pix.payout.returned | returned | Sent PIX was returned | Active |
pix.refund.requested | requested | Refund request received (BACEN infraction); preventive block created on the client balance | Active |
pix.refund.completed | settled / completed | Defense analysis finalized and refund executed (or released) | Active |
pix.return.received | settled | PIX return received (credit) | Active |
pix.infraction.created | ACKNOWLEDGED | PIX infraction reported by counterparty via BACEN DICT; requires defense or triggers automatic preventive block (>R$1k) | Active |
pix.infraction.resolved | CLOSED / CANCELLED | Infraction resolved (admin close, auto-deny or counterparty cancelled) | Active |
pix.infraction.defense_submitted | defense_submitted | Defense submitted by the merchant (portal or API); awaiting BACEN analysis | Active |
webhook.test | test | Manual test. Available only via Admin/Merchant portal — the External API does not expose an endpoint to dispatch tests | Manual dispatch (not External API) |
pix.charge.cancelled is not yet dispatched
The event is in the enum and can be subscribed to, but the system does not have a QR code cancellation flow today. If you subscribe, POST /webhooks responds 201 normally — but no notification will arrive. Continue monitoring pix.charge.expired for the QR's natural lifecycle.
Security
Each notification includes security and identification headers for validation:
| Header | Description |
|---|---|
X-Owem-Signature | HMAC-SHA256 signature of the payload (prefix sha256=). In rare cases (webhook registered without secret), the literal value is unsigned — see note below |
X-Owem-Timestamp | Unix timestamp in seconds of the delivery |
X-Owem-Event-Id | Unique delivery UUID (for deduplication) |
X-Owem-Event-Type | Event type (e.g., pix.charge.paid) |
Content-Type | Always application/json |
User-Agent | Always Owem-Webhook/1.0 — use for whitelisting in firewalls/WAF. Future evolution will follow the pattern Owem-Webhook/{version}; filter by prefix Owem-Webhook/ if you want to be immune to new versions |
Signature unsigned when webhook has no secret
If the webhook was registered without a secret field (legacy scenario), the X-Owem-Signature header literally equals unsigned. This disables HMAC validation on your side. In practice, POST /api/external/webhooks generates a random 64-character secret when the field is omitted (since session 80), so this scenario only appears in very old records or via admin bypass. If you receive unsigned, register a new webhook with an explicit secret and remove the old one.
SHA256 for webhooks vs SHA512 for the API
The API uses HMAC-SHA512 to authenticate requests you send. Webhooks sent by Owem Pay use HMAC-SHA256 in the X-Owem-Signature signature. They are different algorithms -- each in its context.
Validating the Signature
Validate the signature to guarantee the notification was sent by Owem Pay:
const crypto = require('crypto');
function validateWebhook(rawBody, timestamp, signature, secret) {
// rawBody is the RAW request body string (before any JSON parse)
// timestamp is unix seconds (e.g., 1712160000)
const message = `${timestamp}.${rawBody}`;
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(message)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}Use the RAW body, not re-serialized
You must use the exact HTTP request body as the bytes arrived at your application. If you do JSON.parse and then JSON.stringify, the resulting bytes will not be identical to what Owem used for signing, and validation will fail.
In Express/Node: use express.raw({ type: 'application/json' }) or capture the body before any parsing middleware.
In other frameworks: configure your stack to capture the raw body before JSON middleware.
Key ordering in WEBHOOKS: NOT required
For webhook validation (HMAC-SHA256) you do NOT need to sort keys — use the raw body as received in the HTTP request from Owem.
⚠️ Attention — difference vs sending requests: In the HMAC-SHA512 signature of REQUESTS you send, alphabetical key ordering IS mandatory (the Owem server reorders before validating). Do not confuse the two scenarios:
- Webhook received (HMAC-SHA256): validate the raw body without reordering
- Request sent (HMAC-SHA512): sort your keys alphabetically before signing
Always validate
Never process a webhook without validating the signature. This protects against forged requests.
Additionally, validate that X-Owem-Timestamp is within ± 5 minutes of the current time (anti-replay protection — the server does not reject "old" webhooks by default; this check is up to your endpoint as defense-in-depth) and deduplicate events by X-Owem-Event-Id (protection against retries).
Retry Policy
If your URL returns a status different from 2xx (or times out after 30 s), Owem Pay performs up to 8 attempts with exponential backoff. The total time between the first and the eighth attempt is approximately 7h45min:
| Attempt | Delay since previous attempt | Cumulative time |
|---|---|---|
| 1st | — (immediate, eager via Task.start) | ~50–200 ms |
| 2nd | 30 seconds | ~30 s |
| 3rd | 2 minutes | ~2.5 min |
| 4th | 10 minutes | ~12.5 min |
| 5th | 30 minutes | ~42.5 min |
| 6th | 1 hour | ~1.75 h |
| 7th | 2 hours | ~3.75 h |
| 8th | 4 hours | ~7.75 h |
After 8 attempts without success, the webhook_delivery is marked with status failed and is not automatically resent. You can request a manual replay from Owem support providing the X-Owem-Event-Id (or have an operator with admin access replay via the portal).
Delivery status
Each delivery goes through statuses: pending (created, awaiting delivery) → delivered (2xx received) OR failed (8 attempts exhausted) OR expired (replay protection).
About expired: when the Oban worker goes to process the first attempt and the delivery already has more than 5 minutes since its creation (inserted_at), the send is aborted and the status goes directly to expired. This prevents late reprocessing (queue buildup, pod restart, etc.) from dispatching notifications of already old events. Manual replays requested from Owem support go through the internal flag manual_replay: true and bypass this guard — the client receives the notification normally.
Durability
Before attempting the first delivery, the event is persisted in webhook_deliveries in PostgreSQL. If the pod dispatching the webhook crashes during delivery, Oban automatically resumes on the next retry — no event is lost.
Idempotency
Your application must be idempotent: if it receives the same event more than once (identified by X-Owem-Event-Id), it must process it without duplicating effects.
Manual replay via admin
If a delivery failed and you need to resend, the Owem team can perform a manual replay via the admin dashboard. Contact support with the delivery event_id.
Duplicate deliveries (known race condition)
The system uses an "eager delivery" mechanism to speed up the first delivery + an Oban worker as durable retry fallback. In high-concurrency scenarios, these two paths can dispatch the same webhook in parallel (~1 second window). In this situation you receive the same payload twice via HTTP, but with the same X-Owem-Event-Id — it is the same event, not a retry.
To avoid duplicate impact:
- Dedupe by
X-Owem-Event-Id(recommended — unique UUID per delivery, stable across retries and in the race condition above) - Or alternatively dedupe by
end_to_end_id+event_typewhen it makes sense for the event
This is expected behavior, not an error. Legitimate retries (after 5xx/timeout) also reuse the same X-Owem-Event-Id.
External ID in Webhooks
When a transaction was created with external_id, this field is included in the webhook payload within the data object. Use it to correlate the event with the order in your system without needing an additional query.
Endpoint Requirements
- The URL must use HTTPS (unless
allow_insecure: trueon registration) - Must respond with
2xxstatus within 30 seconds (receive_timeoutconfigured in the delivery worker) - The response body is ignored
- Recommended to respond fast (
200 OKimmediately) and process the event asynchronously on your side; long delays reduce throughput and increase chance of retries
Next Steps
- Register Webhook -- create, list, and remove webhooks
- Event Payloads -- examples of each event type