Handling Refunds With Webhooks (Connect Distributor)
This guide is for partners collecting premiums on their own Stripe Connect
platform, as described in
Collecting Premiums on Your Own Platform. Read
that guide first โ this page assumes you already have a payment session, a
registered py_... payment, and issued policies.
The short version: you own the charge, so you execute every refund, and the
transfer reversal is what tells Sitata the refund happened. Sitata reconciles
from Stripe's charge.refunded webhook, never from an API call you make.
The one rule that matters
Always refund with reverse_transfer: true.
The premium settled on Sitata's connected account as a destination payment. A
refund on your platform only becomes visible to Sitata when the accompanying
transfer reversal claws that money back, because the reversal is what marks
the payment on Sitata's account as refunded and fires the charge.refunded
webhook Sitata listens to.
Refunding without reverse_transfer desynchronises the policy
If you refund the traveller but do not reverse the transfer, Sitata never learns about it. The traveller gets their money back and the policy stays active โ Sitata keeps the premium and keeps the risk on its books. There is no reconciliation job that catches this later. Nothing in the Sitata API will show the sale as refunded.
Which webhooks you need to build
They are all Sitata webhooks โ you never build a Stripe one.
| Webhook | Who builds it | Purpose |
|---|---|---|
Stripe charge.refunded / payment.created on Sitata's connected account | Sitata โ already provisioned during onboarding | How Sitata learns your refund happened |
Sitata connect_premium_refund_requested (912) to your endpoint | You | How you learn Sitata needs a refund executed |
Sitata connect_premium_refund_completed (913) to your endpoint | You | How you learn Sitata finished reconciling that refund |
Sitata subscription.closed (906) and subscription.refunded (907) | You, optionally | Policy-level confirmation, one event per affected policy |
912 and 913 are a matched pair scoped to the payment session: one asks,
one confirms. 906 and 907 are scoped to the policy, and fire for every
cancellation and refund, not just Connect ones.
You do not build a Stripe webhook
During onboarding (step 4 of the
Connect guide),
Sitata registers a Stripe webhook endpoint on Sitata's connected account โ
the acct_... you created for Sitata on your platform โ subscribed to exactly
two events:
payment.created โ a premium arrived (used to verify and issue policies)
charge.refunded โ a premium was refunded (used to reconcile and cancel)
That endpoint posts to Sitata and is verified with its own Stripe signing secret. You never see these deliveries and you do not need to forward anything.
Do not delete that endpoint
It lives on Sitata's connected account, but it is visible on your platform. If it is removed or disabled, policy issuance and refund reconciliation both stop silently โ payments still arrive, but Sitata never hears about them. If you re-provision or migrate the connected account, tell Sitata so the webhook can be recreated.
Two ways a refund starts
A. You initiate it (the traveller cancels with you)
The common case. Refund the premium charge on your own platform:
curl https://api.stripe.com/v1/refunds \
-u sk_your_platform_secret_key: \
-d payment_intent="{PREMIUM_PAYMENT_INTENT_ID}" \
-d reverse_transfer=true \
-d refund_application_fee=true
reverse_transfer=trueโ required (see above).refund_application_feeโ your choice. It controls whether your commission comes back to the traveller too, and is governed by your Sitata agreement, not by the API.
No Sitata API call is needed. Do not also call the subscription close endpoint โ Sitata closes the policies itself when the reversal lands, and the close endpoint would attempt a second refund path.
B. Sitata initiates it (the traveller cancels with Sitata)
Sitata cannot move money that sits on your platform, so a cancellation on
Sitata's side becomes a request to you, delivered as a
connect_premium_refund_requested webhook (event 912). You execute the refund
exactly as in case A, and reconciliation completes through the same reversal โ
at which point Sitata sends you connect_premium_refund_completed (913).
Your partner agreement defines the SLA for acting on these.
Why the API path is preferred for cancellations
If a traveller wants to cancel and you can reach the Sitata API, prefer closing the subscription and letting Sitata drive. Sitata then applies its own eligibility rules (notably: a policy that has already started is not refundable) before asking you for money back. Refunding straight from your Stripe dashboard bypasses those rules entirely โ Sitata will still honour and reconcile the refund, but it will not stop you from refunding something that should not have been refunded.
The 912 webhook
Payload
The HTTP body is the full webhook-event envelope; the event-specific fields are
nested under payload.
{
"id": "b1c0f0f6-1f2a-4c1e-9e0a-5b4d3c2a1908",
"created_at": "2026-08-14T18:22:41.512345Z",
"updated_at": "2026-08-14T18:22:41.512345Z",
"payload": {
"event": 912,
"payment_session_id": "5f7c2d9e-3a11-4b52-9c8d-7e1f0a2b3c4d",
"quote_id": "8a2b1c0d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"product_sale_group_id": "8a2b1c0d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"external_payment_id": "py_1PxyzABC123",
"net_amount": 8000,
"currency_code": "USD",
"subscription_ids": [
"c3d4e5f6-a7b8-49c0-b1d2-e3f4a5b6c7d8"
]
},
"last_response": null,
"last_response_status": null,
"next_attempt_at": null,
"completed": false,
"attempt_count": 0,
"company_id": "0d1e2f3a-4b5c-6d7e-8f9a-0b1c2d3e4f5a",
"webhook_endpoint_id": "9f8e7d6c-5b4a-3c2d-1e0f-a9b8c7d6e5f4"
}
Field notes:
| Field | Meaning |
|---|---|
external_payment_id | The py_... payment on Sitata's connected account, i.e. the one you registered at confirm_payment. Use it to find your own PaymentIntent. |
net_amount | The net figure recorded on the session (premium minus your commission). It is not the amount to refund the traveller โ refund the gross premium you charged them. |
quote_id / product_sale_group_id | The same value under two names, kept for compatibility. |
subscription_ids | The policies being cancelled. |
The payload does not tell you how much to refund
There is no refund_amount field and no gross premium in the payload. The
protocol is "refund this sale" โ resolve the gross amount from your own record
of the premium charge, keyed by payment_session_id or external_payment_id.
Store that mapping at purchase time.
The 913 webhook
connect_premium_refund_completed is the counterpart to 912. It fires once
your transfer reversal has landed on Sitata's charge.refunded webhook and
the ledger has been unwound โ that is, after every step in
What Sitata does when the refund lands
has been applied.
It confirms reconciliation, so you do not have to poll the payment session to find out whether Sitata caught up.
Two things to note about when it fires:
- It is not conditional on
912. A refund you initiate yourself (case A) never produces a912, but it still produces a913once the reversal reconciles.913is the signal for both refund paths. - It fires only on a reversal Sitata actually reconciled. If a reversal
arrives for a session already marked refunded โ a second partial refund, or a
redelivery โ reconciliation is skipped and no further
913is sent.
Payload
Identical in shape to 912, with event set to 913:
{
"id": "7d2e1a90-6b3c-4f18-8a52-1c9d0e4b7a63",
"created_at": "2026-08-14T19:04:07.883201Z",
"updated_at": "2026-08-14T19:04:07.883201Z",
"payload": {
"event": 913,
"payment_session_id": "5f7c2d9e-3a11-4b52-9c8d-7e1f0a2b3c4d",
"quote_id": "8a2b1c0d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"product_sale_group_id": "8a2b1c0d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"external_payment_id": "py_1PxyzABC123",
"net_amount": 8000,
"currency_code": "USD",
"subscription_ids": [
"c3d4e5f6-a7b8-49c0-b1d2-e3f4a5b6c7d8"
]
},
"last_response": null,
"last_response_status": null,
"next_attempt_at": null,
"completed": false,
"attempt_count": 0,
"company_id": "0d1e2f3a-4b5c-6d7e-8f9a-0b1c2d3e4f5a",
"webhook_endpoint_id": "9f8e7d6c-5b4a-3c2d-1e0f-a9b8c7d6e5f4"
}
The fields carry the same meanings as in the
912 field notes โ net_amount is still the net figure recorded on
the session at sale time, and is not the amount that was refunded.
913 does not publish the refunded amount
Like 912, it carries no refund_amount. It tells you that Sitata
reconciled the sale, not how much money moved. For the amount, use your own
Stripe refund record โ you created it.
Because the payload matches 912, you can reuse one parser and branch on
payload.event. Match the two events to each other on payment_session_id.
The 906 and 907 webhooks
subscription.closed (906) and subscription.refunded (907) are
policy-level events. Where 912/913 describe the sale, these describe each
individual policy on it, so a sale covering three travellers produces three
of each.
| Event | Name | Fires when |
|---|---|---|
906 | subscription.closed | A policy is closed, for any reason โ a Connect refund reconciling, an API close, an admin cancellation |
907 | subscription.refunded | A refund has been recorded against a policy |
They are not Connect-specific: any cancellation or refund on any policy fires them, including ones where Sitata is the merchant of record.
Who receives them
Each event is delivered to the companies with a stake in the policy: the company the policy belongs to, and the company that sold it. Those are usually the same company, in which case you receive the event once โ endpoints are deduplicated before delivery.
Payload
{
"id": "3c8b7a61-9d24-4e5f-b071-2a3c4d5e6f70",
"created_at": "2026-08-14T19:04:07.114902Z",
"updated_at": "2026-08-14T19:04:07.114902Z",
"payload": {
"event": 906,
"subscription_id": "c3d4e5f6-a7b8-49c0-b1d2-e3f4a5b6c7d8",
"human_id": "ABC123",
"product_sale_group_id": "8a2b1c0d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"currency_code": "USD",
"start_date": "2026-09-01",
"finish_date": "2026-08-14",
"finish": "2026-08-14T19:04:06.998311Z"
},
"last_response": null,
"last_response_status": null,
"next_attempt_at": null,
"completed": false,
"attempt_count": 0,
"company_id": "0d1e2f3a-4b5c-6d7e-8f9a-0b1c2d3e4f5a",
"webhook_endpoint_id": "9f8e7d6c-5b4a-3c2d-1e0f-a9b8c7d6e5f4"
}
| Field | Meaning |
|---|---|
subscription_id | The policy. Matches an entry in the subscription_ids array of 912/913. |
human_id | The short human-readable policy reference shown to travellers. |
product_sale_group_id | The sale the policy belongs to โ the same value 912/913 call quote_id. |
start_date | The date cover was due to begin. Unchanged by closure. |
finish_date / finish | Cover end, as a date and as a UTC timestamp. Closing rewrites both to the moment of closure, so on a 906 they are the cancellation time, not the original policy end. |
No amount, and no reason
Neither event carries a refunded amount, a premium, a cancellation reason, or
the traveller. They tell you a policy's state changed; join back to your own
record on subscription_id or human_id for anything else.
Ordering against 913
During a Connect refund all three events are produced by the same
reconciliation, but deliveries are not ordered โ each is dispatched
independently and retries on its own schedule. Do not assume 913 arrives last,
and do not make 906/907 handling depend on having seen 913 first.
If you want a single "this sale is settled" trigger, use 913 and treat
906/907 as per-policy bookkeeping.
Receiving these webhooks
Registering the endpoint
POST /api/v2/org/:company_id/webhook_endpoints
{
"webhook_endpoint": {
"enabled": true,
"url": "https://www.your-company.co/webhooks/sitata",
"enabled_events": [908, 912, 913, 906, 907]
}
}
url must begin with https://. Leaving enabled_events empty or null
subscribes to every event. The response includes the signing_secret you need
below; you can read it back later with GET .../webhook_endpoints/:id.
Secrets cannot be rotated in place
There is no rotation endpoint. To change a signing secret, create a new endpoint and delete the old one.
Verifying the signature
Every delivery carries a Sitata-Signature header:
Sitata-Signature: t=1755194561,v1=9F86D081884C7D659A2FEAA0C55AD015A3BF4F1B2B0B822CD15D6C15B0F00A08
Recompute it as an HMAC-SHA256 of the string "<t>.<raw request body>",
keyed by the endpoint's signing_secret (used as raw ASCII bytes โ do not
hex-decode it).
const crypto = require("crypto");
function verify(rawBody, header, signingSecret) {
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("="))
);
const expected = crypto
.createHmac("sha256", signingSecret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
// Sitata emits UPPERCASE hex โ compare case-insensitively.
return crypto.timingSafeEqual(
Buffer.from(expected.toLowerCase()),
Buffer.from(parts.v1.toLowerCase())
);
}
Two things that trip people up:
- The signature is uppercase hex. Most language runtimes produce lowercase. Case-fold both sides before comparing.
- Sign the raw body, byte for byte, before any JSON parsing or re-serialisation. Frameworks that re-encode the body will break the signature.
The t value is provided so you can enforce a freshness window. Sitata does not
enforce one itself, so if replay protection matters to you, reject old
timestamps in your own handler.
Idempotency and retries
Respond 2xx quickly, then do the work. Any status outside 200โ299 โ and
any connection error โ counts as a failure.
- Sitata retries up to 12 attempts with base-2 backoff starting at 5 minutes (5m, 10m, 20m, 40m โฆ), spanning roughly 7 days before giving up.
- Deliveries are not ordered. Events are delivered concurrently and each retries on its own schedule.
- There is no request timeout configured, so do not rely on Sitata hanging up โ return promptly and process asynchronously.
Deduplicate on the envelope's top-level id, which is stable across retries of
the same event. Note that attempt_count is 0 on first delivery.
912 is additionally deduplicated at the source: the request is stamped on the
payment session, so at most one 912 is ever sent per payment session, even
when a sale contains several policies and all of them are cancelled. Treat it as
fire-once and make your refund execution idempotent on your side too โ a Stripe
refund is not automatically idempotent, so use a Stripe idempotency key derived
from payment_session_id.
Inspecting deliveries
Sitata keeps a delivery record per event, which you can read:
GET /api/v2/org/:company_id/webhook_events?incomplete=true
PATCH /api/v2/org/:company_id/webhook_events/:id/complete
Each record carries payload, last_response, last_response_status,
attempt_count and completed. completed: true means Sitata stopped trying โ
either it succeeded or it exhausted all 12 attempts, so check
last_response_status to tell those apart. Only the most recent response is
kept; there is no per-attempt history and no manual redelivery endpoint.
What Sitata does when the refund lands
Once your transfer reversal fires charge.refunded on Sitata's connected
account, Sitata:
- Matches the payment to the payment session by
external_payment_id. - Reverses the sale's ledger entries proportionally to the amount refunded, unwinding each currency back into the balance it came from.
- Mirrors the commission settlement back, so the partner and Sitata balances both return to zero for that sale.
- Marks the payment session
refunded. - Closes every still-active policy on the sale โ without asking any gateway
for a second refund โ and emails each traveller a refund confirmation. Each
policy emits a
subscription.refunded(907), and each one that was still active emits asubscription.closed(906). - Sends you
connect_premium_refund_completed(913) for the sale.
Steps 2โ6 are the whole reconciliation. There is nothing for you to call โ wait
for 913.
Edge cases
Partial refunds
Sitata's ledger reversal is proportional, but the policy side is not:
A partial refund still cancels the policy in full
Any refunded amount above zero closes every active policy on the sale and sends the traveller a refund confirmation. There is no partial or pro-rata coverage state. A 10% refund cancels 100% of the cover.
Worse, the sale is flagged refunded on the first reversal, so a second partial refund is recognised and then ignored โ no further ledger reversal happens for it. Sequential partial refunds do not accumulate.
Treat refunds as all-or-nothing. If you need to reduce cover rather than cancel it, cancel and re-purchase instead โ see Endorsements.
Refunds after the policy has started
The two paths behave differently, and the difference is deliberate:
| Path | Behaviour once start has passed |
|---|---|
| Sitata-initiated (closing a subscription) | The subscription is not refundable. It closes, but no refund is issued and no 912 or 913 is sent. You still receive 906 for the closure. |
| You refund on your own platform | Sitata reconciles it anyway โ ledger reversed, policies closed, traveller emailed, 913 sent. There is no start-date check on this path. |
So Sitata will never ask you to refund a started policy, but it will not reject one you push through. Deciding whether a mid-policy refund is appropriate is your responsibility.
Refund currency
Sitata does not create the refund and never specifies a currency โ the refund is
denominated by Stripe, in the currency of your original charge. Refund in the
same currency you charged, matching the session's currency_code.
The exchange-rate tolerance Sitata applies when verifying an incoming premium has no equivalent on the refund path, so a refund that comes back converted at a different rate will not be rate-checked. Cross-currency refunds should be confirmed with Sitata before you rely on them.
Closing a subscription does not confirm a refund
The close endpoint returns 200 as soon as the subscription is closed. The
refund is requested asynchronously, so a success response tells you the policy
closed โ not that any money moved.
Wait for 913 (or your own Stripe charge.refunded) before treating the money
as settled. 906 confirms only that the policy was closed.
Testing your handler
All four events can be fired at your endpoint on demand from the
developer-notification page, POST /dev_notifications โ the same tooling
described under
Testing With The Simulation Endpoints.
warning
/dev_notifications is a developer utility, not part of the versioned
/api/v2 surface, and is enabled only in Sitata's non-production
(development and staging) environments. Point it at your staging
integration.
Authenticate with an authentication_token that resolves to your company and
pass the event as type:
type | Page label |
|---|---|
906 | Subscription Closed |
907 | Subscription Refunded |
912 | Connect: Premium Refund Requested |
913 | Connect: Premium Refund Completed |
These four are built entirely from fake in-memory data. Nothing is written to the database, no policy is closed, no ledger is touched and no money moves โ the only effect is a signed delivery to your endpoint. They are safe to fire repeatedly while you wire up parsing and signature verification.
Optional parameters let you pin the identifiers so they match records you already hold:
| Parameter | Applies to | Effect |
|---|---|---|
subscription_id | 906, 907, 912, 913 | Uses this id instead of a random one. On 912/913 it becomes the single entry in subscription_ids. |
product_sale_group_id | 912, 913 | Uses this id for product_sale_group_id and quote_id. |
Omit them and every id is randomly generated. The remaining fields are fixed
sample values, so do not assert on the amounts or currency โ a simulated
912/913 reports net_amount 8500 in GBP, and a simulated 906/907
reports CAD with the human id ABC123.
Not supported
To save you building against things that do not exist:
- No refunded amount is published in any Sitata webhook. Neither
912,913,906nor907carries one. - Sitata never issues a partial refund on this flow, and there is no API to request one.
913is not a receipt for a specific refund. It confirms that a sale was reconciled; it does not identify which of several reversals triggered it, and a second partial refund on an already-refunded session produces no913at all.- Disputes are not refunds. See the commercial notes โ a disputed premium debits your platform first, and you recover it from Sitata by reversing the transfer.
If anything here does not match what you are seeing, contact Sitata before building a workaround.