Skip to main content

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.

WebhookWho builds itPurpose
Stripe charge.refunded / payment.created on Sitata's connected accountSitata โ€” already provisioned during onboardingHow Sitata learns your refund happened
Sitata connect_premium_refund_requested (912) to your endpointYouHow you learn Sitata needs a refund executed
Sitata connect_premium_refund_completed (913) to your endpointYouHow you learn Sitata finished reconciling that refund
Sitata subscription.closed (906) and subscription.refunded (907)You, optionallyPolicy-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:

FieldMeaning
external_payment_idThe py_... payment on Sitata's connected account, i.e. the one you registered at confirm_payment. Use it to find your own PaymentIntent.
net_amountThe 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_idThe same value under two names, kept for compatibility.
subscription_idsThe 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 a 912, but it still produces a 913 once the reversal reconciles. 913 is 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 913 is 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.

EventNameFires when
906subscription.closedA policy is closed, for any reason โ€” a Connect refund reconciling, an API close, an admin cancellation
907subscription.refundedA 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"
}
FieldMeaning
subscription_idThe policy. Matches an entry in the subscription_ids array of 912/913.
human_idThe short human-readable policy reference shown to travellers.
product_sale_group_idThe sale the policy belongs to โ€” the same value 912/913 call quote_id.
start_dateThe date cover was due to begin. Unchanged by closure.
finish_date / finishCover 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:

  1. Matches the payment to the payment session by external_payment_id.
  2. Reverses the sale's ledger entries proportionally to the amount refunded, unwinding each currency back into the balance it came from.
  3. Mirrors the commission settlement back, so the partner and Sitata balances both return to zero for that sale.
  4. Marks the payment session refunded.
  5. 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 a subscription.closed (906).
  6. 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:

PathBehaviour 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 platformSitata 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:

typePage label
906Subscription Closed
907Subscription Refunded
912Connect: Premium Refund Requested
913Connect: 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:

ParameterApplies toEffect
subscription_id906, 907, 912, 913Uses this id instead of a random one. On 912/913 it becomes the single entry in subscription_ids.
product_sale_group_id912, 913Uses 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, 906 nor 907 carries one.
  • Sitata never issues a partial refund on this flow, and there is no API to request one.
  • 913 is 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 no 913 at 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.