Developers · Webhooks

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.

How it works

You set it up. Unio sends, retries, and shows you what happened.

  1. In your Unio vendor portal, open Webhooks, enter an https address, and copy your client id and client secret. The secret is shown once.
  2. Tick the events you want. practice.subscribed and practice.access_ended are always sent: they say when to give and remove access.
  3. Press Send test event. Answer 2xx once the signature checks out.
  4. 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.
You hear only about orders you are part of: products you fulfil, and products whose revenue split pays you. Nothing from before your endpoint was set up is sent.
The request

One JSON POST per event.

HeaderWhat it is
Content-Typeapplication/json
User-Agentunio/1.0
X-Client-IdYour client id (unio_ci_…), so you know whose secret signed it
Idempotency-KeyThe envelope’s idempotency_key
X-Unio-Event-IdThe envelope’s id
X-Unio-Event-TypeThe envelope’s type
X-Signaturet=<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.

Security

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.

Node.js
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)
  })
}
Ruby (Rails)
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) }
end

Test your check

With the secret unio_cs_example_do_not_use, this body and t must give exactly this header:

html
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=64dd2715e5c93da75ddbe523a6efe0c63cc9d7feb65599c2c5b4c4a8c2220a29
The envelope

Every event has the same outside.

FieldWhat it is
idThis message’s own id, for logs and support. A retry or resend keeps it. Random: do not drop repeats by it
api_versionThe 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
typeWhat happened (the events below). Ignore types you do not know
createdWhen it happened at Stripe, unix seconds. Events can arrive out of order: compare this, not arrival time
order_idThe Unio store order. The same id as source.order_id in the enrollment call. null only for unio.test
dataDepends 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.

Answering

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

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 isWhenWhat happens
HealthyThe last event got throughEvents are sent as they happen.
FailingA try failed and nothing has been delivered sinceRetries go on. When an event gives up, your owners and admins get an email, at most one every 24 hours.
PausedFailing for 3 days with nothing deliveredUnio 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.

Events

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 fieldWhat it is
subscription_idStripe subscription id
invoice_idThe paid invoice; null on a trial
amount_centsAmount paid; 0 on a trial
currencyLower-case ISO code; null on a trial
paid_atWhen it was paid, ISO 8601 UTC; absent on a trial
period_startStart of the paid period, ISO 8601 UTC
period_endEnd of the paid period, ISO 8601 UTC
statusactive, or trialing
trial_endWhen 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
customerOnly 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
Example body
{
  "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 fieldWhat it is
subscription_idStripe subscription id
invoice_idThe paid invoice
amount_centsAmount paid
currencyLower-case ISO code
paid_atWhen it was paid, ISO 8601 UTC
period_startStart of the paid period, ISO 8601 UTC
period_endEnd 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
Example body
{
  "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 fieldWhat it is
subscription_idStripe subscription id
statusStill active (or trialing)
period_startStart of the paid period, ISO 8601 UTC
period_endEnd of the paid period, ISO 8601 UTC
canceled_atWhen they cancelled, ISO 8601 UTC
access_ends_atWhen access will end
your_shareAlways null: no money moved
Example body
{
  "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 fieldWhat it is
subscription_idStripe subscription id
statusactive or trialing
period_startStart of the paid period, ISO 8601 UTC
period_endEnd of the paid period, ISO 8601 UTC
your_shareAlways null: no money moved
Example body
{
  "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 fieldWhat it is
subscription_idStripe subscription id
statusStripe’s final status, e.g. canceled
period_startStart of the paid period, ISO 8601 UTC
period_endEnd of the paid period, ISO 8601 UTC
ended_atWhen it ended, ISO 8601 UTC
reasonStripe’s reason, e.g. cancellation_requested or payment_failed; may be null
your_shareAlways null: no money moved
Example body
{
  "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 fieldWhat it is
refund_idStripe refund id
charge_idThe refunded charge
amount_centsThis refund
currencyLower-case ISO code
reasonStripe’s reason; may be null
charge_amount_centsThe whole charge
charge_refunded_centsAll refunds on the charge so far
full_refundtrue 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
Example body
{
  "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 fieldWhat it is
dispute_idStripe dispute id
charge_idThe disputed charge
amount_centsDisputed amount
currencyLower-case ISO code
reasonStripe’s reason, e.g. fraudulent
statusStripe’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
Example body
{
  "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 fieldWhat it is
dispute_idStripe dispute id
charge_idThe disputed charge
amount_centsDisputed amount
currencyLower-case ISO code
reasonStripe’s reason, e.g. fraudulent
statuswon, 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
Example body
{
  "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 fieldWhat it is
messageA fixed sentence
Example 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."
  }
}

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.