Webhooks
Two directions, and they are completely separate systems:
| Direction | Meaning | Where |
|---|---|---|
| Outbound | Axowl → your server | /api/org/{slug}/webhook-endpoints (register) |
| Inbound | A provider → Axowl | /api/v1/webhooks/{portone,stripe} |
Outbound — Axowl calls you
Section titled “Outbound — Axowl calls you”Register an HTTPS endpoint, subscribe it to event names, and Axowl POSTs a signed JSON envelope when those events happen.
Register an endpoint
Section titled “Register an endpoint”POST /api/org/{slug}/webhook-endpointsContent-Type: application/json
{ "url": "https://example.com/hooks/axowl", "events": ["decision.*"], "description": "review queue callback" }The response carries the signing secret once — it is never returned again by any other call:
{ "id": "…", "secret": "whsec_…" }| Method · Route | Purpose |
|---|---|
GET /webhook-endpoints | List (never includes the secret) |
POST /webhook-endpoints | Create → returns secret once |
PUT /webhook-endpoints/{id} | Change URL / events / description |
POST /webhook-endpoints/{id}/rotate-secret | New secret → old signatures stop verifying immediately |
POST /webhook-endpoints/{id}/toggle | Enable / disable (enabling also clears the failure counter) |
DELETE /webhook-endpoints/{id} | Soft delete |
Limits: 20 endpoints per organization, and an endpoint with no subscribed events is rejected at registration — an endpoint that can never fire is a bug, not a configuration.
Subscribing to events
Section titled “Subscribing to events”events is a list of patterns:
| Pattern | Matches |
|---|---|
"*" | everything |
"decision.*" | decision.approved, decision.a.b — not decisionx.y (the dot boundary is required) |
"decision.approved" | exactly that name, case-insensitive |
An empty or malformed list delivers nothing. This is deliberate: the safe failure for an egress path is silence, not “send everything”.
The envelope
Section titled “The envelope”{ "id": "9f1c…", // unique per delivery — use it to de-duplicate "type": "decision.approved", // the event name you subscribed to "timestamp": "2027-01-02T03:04:05.0000000Z", "data": { } // event payload}Verifying the signature
Section titled “Verifying the signature”Signatures follow Standard Webhooks — the same scheme Stripe and Svix use, so an existing verification library works unchanged.
webhook-id: 9f1c…webhook-timestamp: 1800000000webhook-signature: v1,<base64>signedContent = "{webhook-id}.{webhook-timestamp}.{raw body}"expected = base64( HMAC-SHA256( base64decode(secret minus "whsec_"), signedContent ) )Compare in constant time, and reject a webhook-timestamp more than 5 minutes from your clock
— that window is what stops a captured request from being replayed.
Delivery behavior
Section titled “Delivery behavior”- Timeout 10 seconds. Any non-2xx, timeout, or connection failure counts as a failure.
- No automatic retry today. Treat delivery as at-most-once and reconcile from your own state if a decision matters.
- Circuit breaker — after 20 consecutive failures the endpoint disables itself. Re-enable it
with
POST /{id}/toggle({"isActive": true}), which also resets the counter. A single success resets it too. - Egress is guarded — URLs that resolve to private, loopback, or cloud-metadata addresses are refused, at registration and again at send time (so DNS rebinding does not help).
- No secret, no send. If an endpoint’s signing secret is missing or malformed, Axowl skips the delivery rather than sending something you cannot distinguish from a forgery.
Inbound — providers call Axowl
Section titled “Inbound — providers call Axowl”Two inbound webhooks under /api/v1/webhooks. They are identity webhooks — not payment webhooks.
| Method · Route | Purpose | Status | Source |
|---|---|---|---|
POST /portone | Korean real-name verification — Korea only | ✅ Active | WebhookEndpoints.cs:55 |
POST /stripe | Stripe Identity (global KYC) | ⚠️ Present, not used | :195 |
PortOne — real-name verification, Korea only
Section titled “PortOne — real-name verification, Korea only”Axowl’s real-name identity verification runs through PortOne (V2) and is Korea-only — routing is keyed on User.CountryIso. It verifies the representative/owner of a Korean org (mobile phone, card, KakaoTalk/Naver certificate, or the three telecom carriers). This webhook is the safety net for when the client’s Finalize call fails: on an IdentityVerification.Verified event it decodes the user from identityVerificationId (axowl-kid-{userId}-{ts}) and runs FinalizeKoreanIdentityVerificationCommand — idempotent, so it can race the client’s own call harmlessly.
Signatures use the Standard Webhooks scheme (PortOneWebhookVerifier): webhook-id / webhook-timestamp / webhook-signature headers, HMAC-SHA256, ±5-minute replay window. Until PortOne:WebhookSecret is configured it fails open (skips verification with a warning); once set, a bad signature returns 401.
Stripe Identity — present, not used
Section titled “Stripe Identity — present, not used”The /stripe handler processes Stripe Identity identity.verification_session.verified (a global KYC flow → User.Level). Axowl has decided not to do global KYC, so this path is retained in code but not used — kept intentionally, not deleted. Note this is Stripe Identity, not Stripe payments: Axowl takes no payments through Stripe.
Payments are not on this surface
Section titled “Payments are not on this surface”Neither inbound webhook carries a payment, and nothing you integrate against here touches money. The naming is the trap, so state it plainly:
/api/v1/webhooks/stripe | Stripe Identity — a KYC product. Not Stripe payments. Present, unused. |
/api/v1/webhooks/portone | PortOne real-name verification. Not PortOne payments. |
Axowl’s own billing runs on a separate domain with its own endpoints, routed by User.CountryIso:
- Korea → KG Inicis via PortOne — local methods plus tax invoices and cash receipts. Payment callbacks arrive at their own path, deliberately kept apart from the identity webhook above.
- Everywhere else → Stripe (Checkout; Stripe Connect for marketplace settlement).