Partner webhooks.
When a practice buys, renews, cancels, loses access, is refunded or disputes a payment for a store product you fulfil or are paid from, Unio sends your server a signed POST. This page is every event, every field and an example of each. API version 2026-10-26.
You set it up. Unio sends, retries, and shows you what happened.
- In your Unio vendor portal, open Webhooks, enter an https address, and copy your client id and client secret. The secret is shown once.
- Tick the events you want.
practice.subscribedandpractice.access_endedare always sent: they say when to give and remove access. - Press Send test event. Answer 2xx once the signature checks out.
- From then on, every event is listed on that tab with its status, the error it got, its next try and the exact request. You can resend any of them.
One JSON POST per event.
| Header | What it is |
|---|---|
Content-Type | application/json |
User-Agent | unio/1.0 |
X-Client-Id | Your client id (unio_ci_…), so you know whose secret signed it |
Idempotency-Key | The envelope’s idempotency_key |
X-Unio-Event-Id | The envelope’s id |
X-Unio-Event-Type | The envelope’s type |
X-Signature | t=<unix seconds>,v1=<hex>: HMAC-SHA256 of "<t>.<raw body>" with your client secret. During a secret change there are two v1 values; accept the request when any matches |
Redirects are not followed, and Unio waits 10 seconds for an answer.
Check the signature before anything else.
Compute HMAC-SHA256 of "<t>.<raw body>" with your client secret, hex-encode it, and compare it with each v1 in constant time. Reject a t more than 5 minutes from your clock. Use the raw body: parsed and re-serialised JSON has different bytes and never matches.
import crypto from 'node:crypto'
// rawBody: the request body exactly as received (a Buffer or string), never re-serialised JSON.
export function verifyUnio(rawBody, header, secret, toleranceSec = 300) {
const parts = header.split(',').map(p => p.trim().split('='))
const t = Number(parts.find(([k]) => k === 't')?.[1])
const sigs = parts.filter(([k]) => k === 'v1').map(([, v]) => v)
if (!Number.isInteger(t) || !sigs.length) return false
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false // replay window
const want = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest()
return sigs.some(v => {
const got = Buffer.from(v, 'hex')
return got.length === want.length && crypto.timingSafeEqual(got, want)
})
}require "openssl"
# raw_body: request.raw_post, never params.to_json.
def verify_unio(raw_body, header, secret, tolerance: 300)
parts = header.split(",").map { |p| p.strip.split("=", 2) }
t = parts.find { |k, _| k == "t" }&.last.to_i
sigs = parts.select { |k, _| k == "v1" }.map(&:last)
return false if t.zero? || sigs.empty?
return false if (Time.now.to_i - t).abs > tolerance # replay window
want = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{raw_body}")
sigs.any? { |v| ActiveSupport::SecurityUtils.secure_compare(v, want) }
endTest your check
With the secret unio_cs_example_do_not_use, this body and t must give exactly this header:
t = 1792580000
body = {"id":"evt_uniotest0f3a9c","api_version":"2026-10-26","idempotency_key":"test:unio.test:evt_uniotest0f3a9c","type":"unio.test","created":1792580000,"order_id":null,"data":{"message":"A test event from Unio. Answer 2xx once the signature checks out."}}
X-Signature: t=1792580000,v1=64dd2715e5c93da75ddbe523a6efe0c63cc9d7feb65599c2c5b4c4a8c2220a29Every event has the same outside.
| Field | What it is |
|---|---|
id | This message’s own id, for logs and support. A retry or resend keeps it. Random: do not drop repeats by it |
api_version | The envelope’s shape, 2026-10-26. A new date only when a field changes meaning or is removed; new fields can appear at any time, so ignore ones you do not know |
idempotency_key | <order_id>:<type>:<fact>. The same thing that happened always has the same key: on a retry, a resend, or a Stripe replay. Drop repeats by this |
type | What happened (the events below). Ignore types you do not know |
created | When it happened at Stripe, unix seconds. Events can arrive out of order: compare this, not arrival time |
order_id | The Unio store order. The same id as source.order_id in the enrollment call. null only for unio.test |
data | Depends on type. Amounts are integer cents; times ISO 8601 UTC or null |
Store the idempotency_keys you have handled with a unique index and keep them at least 30 days. On a key you have seen, answer 2xx and do nothing.
Any 2xx means you have it.
- Answer quickly, then do the work: a slow answer counts as a failure and the event is sent again.
- Anything else (4xx, 5xx, a redirect, a timeout, a bad certificate) is a failure and is retried.
- Unio never reads your response body.
Retried for about 3 days. Paused, never dropped.
A failed event is tried again after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 3 hours, 6 hours, 12 hours, 1 day, 1 day: about 71 hours in all. Each wait moves by up to 20% either way. After the last try it shows as Gave up on your Webhooks tab, where you can resend it.
| Your endpoint is | When | What happens |
|---|---|---|
| Healthy | The last event got through | Events are sent as they happen. |
| Failing | A try failed and nothing has been delivered since | Retries go on. When an event gives up, your owners and admins get an email, at most one every 24 hours. |
| Paused | Failing for 3 days with nothing delivered | Unio stops sending and emails you. New events keep queuing. Fix the endpoint, send a test event, then press Resume: everything that waited, and every event that gave up while failing, is sent again. |
Test events never change your health, either way, and they still run while paused.
Access starts with practice.subscribed and ends only with practice.access_ended.
A refund, a dispute or a cancellation does not end access by itself. A failed payment is not sent: if Stripe gives up collecting, you get practice.access_ended.
practice.subscribed · always sent
The first payment went through, or a free trial began. Give the practice access. When a Unio admin must check the order first, it arrives once they approve it.
data field | What it is |
|---|---|
subscription_id | Stripe subscription id |
invoice_id | The paid invoice; null on a trial |
amount_cents | Amount paid; 0 on a trial |
currency | Lower-case ISO code; null on a trial |
paid_at | When it was paid, ISO 8601 UTC; absent on a trial |
period_start | Start of the paid period, ISO 8601 UTC |
period_end | End of the paid period, ISO 8601 UTC |
status | active, or trialing |
trial_end | When the trial ends; null when paid |
your_share | { amount_cents, currency, transfer_status } when you are in the revenue split, else null. transfer_status: paid, pending, failed (Unio owes it and retries) or reversed |
customer | Only to the partner a community picked to set its buyers up, for buyers who checked out after that: { practice: { id, name, google_place_id, address, phone, website, practice_software }, owner: { first_name, last_name, email, phone, signed_in_with }, terms_accepted_at }. Any field nobody gave is null. signed_in_with is google, facebook or email: the login the owner chose at checkout. Match the owner by email, never by a Google or Facebook id. Other partners get the event without it |
{
"id": "evt_practicesu0f3a9c",
"api_version": "2026-10-26",
"idempotency_key": "cm4order7k2:practice.subscribed:sub_1QxLakeside",
"type": "practice.subscribed",
"created": 1792580000,
"order_id": "cm4order7k2",
"data": {
"subscription_id": "sub_1QxLakeside",
"invoice_id": "in_1QxFirst",
"amount_cents": 15000,
"currency": "usd",
"paid_at": "2026-10-21T10:53:20.000Z",
"period_start": "2026-10-21T10:53:20.000Z",
"period_end": "2026-11-20T10:53:20.000Z",
"status": "active",
"trial_end": null,
"your_share": {
"amount_cents": 4313,
"currency": "usd",
"transfer_status": "paid"
},
"customer": {
"practice": {
"id": "cm4practice9x",
"name": "Lakeside Family Dental",
"google_place_id": "ChIJLakesideAustin",
"address": {
"line": "12 Main St, Austin, TX 78701",
"street": "12 Main St",
"city": "Austin",
"state": "TX",
"postal_code": "78701"
},
"phone": "+1 512 555 0100",
"website": "https://lakeside.example",
"practice_software": "dentrix"
},
"owner": {
"first_name": "Dana",
"last_name": "Lopez",
"email": "dana@lakeside.example",
"phone": "+1 512 555 0101",
"signed_in_with": "google"
},
"terms_accepted_at": "2026-10-21T10:53:20.000Z"
}
}
}practice.renewed
A later invoice was paid, including the first one after a trial.
data field | What it is |
|---|---|
subscription_id | Stripe subscription id |
invoice_id | The paid invoice |
amount_cents | Amount paid |
currency | Lower-case ISO code |
paid_at | When it was paid, ISO 8601 UTC |
period_start | Start of the paid period, ISO 8601 UTC |
period_end | End of the paid period, ISO 8601 UTC |
your_share | { amount_cents, currency, transfer_status } when you are in the revenue split, else null. transfer_status: paid, pending, failed (Unio owes it and retries) or reversed |
{
"id": "evt_practicere0f3a9c",
"api_version": "2026-10-26",
"idempotency_key": "cm4order7k2:practice.renewed:in_1QxRenew",
"type": "practice.renewed",
"created": 1795172000,
"order_id": "cm4order7k2",
"data": {
"subscription_id": "sub_1QxLakeside",
"invoice_id": "in_1QxRenew",
"amount_cents": 15000,
"currency": "usd",
"paid_at": "2026-10-21T10:53:20.000Z",
"period_start": "2026-10-21T10:53:20.000Z",
"period_end": "2026-11-20T10:53:20.000Z",
"your_share": {
"amount_cents": 4313,
"currency": "usd",
"transfer_status": "paid"
}
}
}practice.cancelled
The practice set it to stop. Access continues until access_ends_at: do not remove it yet.
data field | What it is |
|---|---|
subscription_id | Stripe subscription id |
status | Still active (or trialing) |
period_start | Start of the paid period, ISO 8601 UTC |
period_end | End of the paid period, ISO 8601 UTC |
canceled_at | When they cancelled, ISO 8601 UTC |
access_ends_at | When access will end |
your_share | Always null: no money moved |
{
"id": "evt_practiceca0f3a9c",
"api_version": "2026-10-26",
"idempotency_key": "cm4order7k2:practice.cancelled:sub_1QxLakeside:1792666400",
"type": "practice.cancelled",
"created": 1792666400,
"order_id": "cm4order7k2",
"data": {
"subscription_id": "sub_1QxLakeside",
"status": "active",
"period_start": "2026-10-21T10:53:20.000Z",
"period_end": "2026-11-20T10:53:20.000Z",
"canceled_at": "2026-10-22T10:53:20.000Z",
"access_ends_at": "2026-11-20T10:53:20.000Z",
"your_share": null
}
}practice.cancel_undone
A cancellation was undone before it took effect. Nothing ends.
data field | What it is |
|---|---|
subscription_id | Stripe subscription id |
status | active or trialing |
period_start | Start of the paid period, ISO 8601 UTC |
period_end | End of the paid period, ISO 8601 UTC |
your_share | Always null: no money moved |
{
"id": "evt_practiceca0f3a9c",
"api_version": "2026-10-26",
"idempotency_key": "cm4order7k2:practice.cancel_undone:sub_1QxLakeside:1792666400",
"type": "practice.cancel_undone",
"created": 1792752800,
"order_id": "cm4order7k2",
"data": {
"subscription_id": "sub_1QxLakeside",
"status": "active",
"period_start": "2026-10-21T10:53:20.000Z",
"period_end": "2026-11-20T10:53:20.000Z",
"your_share": null
}
}practice.access_ended · always sent
Access is gone now. Remove it. You only get this for a subscription you were told had started.
data field | What it is |
|---|---|
subscription_id | Stripe subscription id |
status | Stripe’s final status, e.g. canceled |
period_start | Start of the paid period, ISO 8601 UTC |
period_end | End of the paid period, ISO 8601 UTC |
ended_at | When it ended, ISO 8601 UTC |
reason | Stripe’s reason, e.g. cancellation_requested or payment_failed; may be null |
your_share | Always null: no money moved |
{
"id": "evt_practiceac0f3a9c",
"api_version": "2026-10-26",
"idempotency_key": "cm4order7k2:practice.access_ended:sub_1QxLakeside",
"type": "practice.access_ended",
"created": 1795172000,
"order_id": "cm4order7k2",
"data": {
"subscription_id": "sub_1QxLakeside",
"status": "canceled",
"period_start": "2026-10-21T10:53:20.000Z",
"period_end": "2026-11-20T10:53:20.000Z",
"ended_at": "2026-11-20T10:53:20.000Z",
"reason": "cancellation_requested",
"your_share": null
}
}practice.refunded
All or part of a charge was refunded. This does not end access by itself.
data field | What it is |
|---|---|
refund_id | Stripe refund id |
charge_id | The refunded charge |
amount_cents | This refund |
currency | Lower-case ISO code |
reason | Stripe’s reason; may be null |
charge_amount_cents | The whole charge |
charge_refunded_cents | All refunds on the charge so far |
full_refund | true once the whole charge is refunded |
your_share | { amount_cents, reversed_cents, currency }: what you were paid from that charge, and what has been taken back so far. null when you were not paid from it |
{
"id": "evt_practicere0f3a9c",
"api_version": "2026-10-26",
"idempotency_key": "cm4order7k2:practice.refunded:re_3QxBack",
"type": "practice.refunded",
"created": 1792839200,
"order_id": "cm4order7k2",
"data": {
"refund_id": "re_3QxBack",
"charge_id": "ch_3QxPaid",
"amount_cents": 15000,
"currency": "usd",
"reason": "requested_by_customer",
"charge_amount_cents": 15000,
"charge_refunded_cents": 15000,
"full_refund": true,
"your_share": {
"amount_cents": 4313,
"reversed_cents": 4313,
"currency": "usd"
}
}
}practice.payment_disputed
The buyer’s bank disputed a charge. This does not end access by itself.
data field | What it is |
|---|---|
dispute_id | Stripe dispute id |
charge_id | The disputed charge |
amount_cents | Disputed amount |
currency | Lower-case ISO code |
reason | Stripe’s reason, e.g. fraudulent |
status | Stripe’s status, e.g. needs_response |
your_share | { amount_cents, reversed_cents, currency }: what you were paid from that charge, and what has been taken back so far. null when you were not paid from it |
{
"id": "evt_practicepa0f3a9c",
"api_version": "2026-10-26",
"idempotency_key": "cm4order7k2:practice.payment_disputed:dp_1QxBank",
"type": "practice.payment_disputed",
"created": 1793012000,
"order_id": "cm4order7k2",
"data": {
"dispute_id": "dp_1QxBank",
"charge_id": "ch_3QxPaid",
"amount_cents": 15000,
"currency": "usd",
"reason": "fraudulent",
"status": "needs_response",
"your_share": {
"amount_cents": 4313,
"reversed_cents": 4313,
"currency": "usd"
}
}
}practice.dispute_closed
The dispute was decided. A lost dispute takes your share back.
data field | What it is |
|---|---|
dispute_id | Stripe dispute id |
charge_id | The disputed charge |
amount_cents | Disputed amount |
currency | Lower-case ISO code |
reason | Stripe’s reason, e.g. fraudulent |
status | won, lost, or warning_closed (an inquiry that ended without a dispute) |
your_share | { amount_cents, reversed_cents, currency }: what you were paid from that charge, and what has been taken back so far. null when you were not paid from it |
{
"id": "evt_practicedi0f3a9c",
"api_version": "2026-10-26",
"idempotency_key": "cm4order7k2:practice.dispute_closed:dp_1QxBank",
"type": "practice.dispute_closed",
"created": 1796036000,
"order_id": "cm4order7k2",
"data": {
"dispute_id": "dp_1QxBank",
"charge_id": "ch_3QxPaid",
"amount_cents": 15000,
"currency": "usd",
"reason": "fraudulent",
"status": "lost",
"your_share": {
"amount_cents": 4313,
"reversed_cents": 4313,
"currency": "usd"
}
}
}unio.test · always sent
You (or Unio) pressed “Send test event”. Not about any order: answer 2xx once the signature checks out.
data field | What it is |
|---|---|
message | A fixed sentence |
{
"id": "evt_uniotest0f3a9c",
"api_version": "2026-10-26",
"idempotency_key": "test:unio.test:evt_uniotest0f3a9c",
"type": "unio.test",
"created": 1792580000,
"order_id": null,
"data": {
"message": "A test event from Unio. Answer 2xx once the signature checks out."
}
}There is no API to ask for an order’s state yet: these events are the whole contract. Signing in with Unio is on the main developer page.