Skip to content

Webhooks

Two directions, and they are completely separate systems:

DirectionMeaningWhere
OutboundAxowl → your server/api/org/{slug}/webhook-endpoints (register)
InboundA provider → Axowl/api/v1/webhooks/{portone,stripe}

Register an HTTPS endpoint, subscribe it to event names, and Axowl POSTs a signed JSON envelope when those events happen.

POST /api/org/{slug}/webhook-endpoints
Content-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 · RoutePurpose
GET /webhook-endpointsList (never includes the secret)
POST /webhook-endpointsCreate → returns secret once
PUT /webhook-endpoints/{id}Change URL / events / description
POST /webhook-endpoints/{id}/rotate-secretNew secret → old signatures stop verifying immediately
POST /webhook-endpoints/{id}/toggleEnable / 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.

events is a list of patterns:

PatternMatches
"*"everything
"decision.*"decision.approved, decision.a.bnot 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”.

{
"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
}

Signatures follow Standard Webhooks — the same scheme Stripe and Svix use, so an existing verification library works unchanged.

webhook-id: 9f1c…
webhook-timestamp: 1800000000
webhook-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.

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

Two inbound webhooks under /api/v1/webhooks. They are identity webhooks — not payment webhooks.

Method · RoutePurposeStatusSource
POST /portoneKorean real-name verification — Korea only✅ ActiveWebhookEndpoints.cs:55
POST /stripeStripe 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.

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.

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/stripeStripe Identity — a KYC product. Not Stripe payments. Present, unused.
/api/v1/webhooks/portonePortOne real-name verification. Not PortOne payments.

Axowl’s own billing runs on a separate domain with its own endpoints, routed by User.CountryIso:

  • KoreaKG 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 elseStripe (Checkout; Stripe Connect for marketplace settlement).