Webhooks for developers

You receive signed POST notifications when something happens in your store — a paid order, a new contact, a confirmed booking. Wire them to Zapier, Make, or your own server.

Back to help center · Open webhook settings in the dashboard

Quick setup

  • In the dashboard go to Integrations → Webhooks, add your HTTPS URL, and select the events you need.
  • Copy the secret immediately — it is shown once. You use it as the HMAC SHA-256 key. The “Test” event has type webhook.test and the same message shape.
  • Respond with HTTP 2xx within a few seconds. If your handler is slow, enqueue the work and answer quickly.

Message shape

  • Each delivery is JSON with id, type, api_version, created_at, store_id, and data.
  • Fields in data are flat (no nested objects) so Zapier and Make can map them easily.
  • id is stable for the same action — use it to ignore duplicates when we redeliver.

X-Mia-Signature · API 2026-11-03 · tolerance 300s

Verify the signature

  • The signature header carries the timestamp and the v1 HMAC: t=<unix>,v1=<hex>.
  • The signed message is {timestamp}.{rawBody} — the exact bytes received, with no extra whitespace.
  • Reject requests whose timestamp is outside the tolerance shown below (replay protection).
  • After you rotate the secret, the old secret keeps signing for 24 hours. In that window the header carries two v1 values; accept the request if either one matches.

Use the raw request body — never re-serialize JSON.

Node.js

import crypto from 'node:crypto';

const SECRET = process.env.MIA_WEBHOOK_SECRET!; // whsec_… from Mia dashboard
const TOLERANCE_SEC = 300;

export function verifyMiaWebhook(rawBody: string, signatureHeader: string | null) {
  if (!signatureHeader) return { ok: false, error: 'missing_header' };

  // During a secret rotation the header carries one v1 per valid secret.
  let timestamp: number | null = null;
  const signatures: string[] = [];
  for (const part of signatureHeader.split(',')) {
    const [k, v] = part.trim().split('=');
    if (k === 't') timestamp = Number(v);
    if (k === 'v1' && v) signatures.push(v);
  }
  if (!Number.isFinite(timestamp!) || signatures.length === 0) return { ok: false, error: 'invalid_header' };

  const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp!);
  if (age > TOLERANCE_SEC) return { ok: false, error: 'timestamp_outside_tolerance' };

  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  const a = Buffer.from(expected, 'utf8');
  const matched = signatures.some((sig) => {
    const b = Buffer.from(sig, 'utf8');
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  });
  if (!matched) return { ok: false, error: 'signature_mismatch' };
  return { ok: true, body: JSON.parse(rawBody) };
}

// Express: app.post('/hooks/mia', express.raw({ type: 'application/json' }), (req, res) => {
//   const result = verifyMiaWebhook(req.body.toString('utf8'), req.get('X-Mia-Signature'));
//   if (!result.ok) return res.status(400).send(result.error);
//   res.sendStatus(200);
// });

Python

import hashlib
import hmac
import json
import os
import time

SECRET = os.environ["MIA_WEBHOOK_SECRET"].encode("utf-8")  # whsec_… from Mia dashboard
TOLERANCE_SEC = 300
HEADER = "X-Mia-Signature"


def verify_mia_webhook(raw_body: bytes, signature_header: str | None):
    if not signature_header:
        return False, "missing_header"

    # During a secret rotation the header carries one v1 per valid secret.
    timestamp = None
    signatures = []
    for part in signature_header.split(","):
        key, _, value = part.strip().partition("=")
        if key == "t":
            timestamp = int(value)
        if key == "v1" and value:
            signatures.append(value)

    if timestamp is None or not signatures:
        return False, "invalid_header"

    if abs(int(time.time()) - timestamp) > TOLERANCE_SEC:
        return False, "timestamp_outside_tolerance"

    signed = f"{timestamp}.".encode("utf-8") + raw_body
    expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
    if not any(hmac.compare_digest(expected, sig) for sig in signatures):
        return False, "signature_mismatch"

    return True, json.loads(raw_body)


# Flask example:
# @app.post("/hooks/mia")
# def mia_hook():
#     raw = request.get_data()
#     ok, payload = verify_mia_webhook(raw, request.headers.get(HEADER))
#     if not ok:
#         return payload, 400
#     return "", 200

PHP

<?php
declare(strict_types=1);

$secret = getenv('MIA_WEBHOOK_SECRET'); // whsec_… from Mia dashboard
$toleranceSec = 300;

function verify_mia_webhook(string $rawBody, ?string $header): array {
    global $secret, $toleranceSec;
    if ($header === null || $header === '') {
        return ['ok' => false, 'error' => 'missing_header'];
    }
    // During a secret rotation the header carries one v1 per valid secret.
    $timestamp = null;
    $signatures = [];
    foreach (explode(',', $header) as $part) {
        [$k, $v] = array_pad(explode('=', trim($part), 2), 2, null);
        if ($k === 't') $timestamp = (int) $v;
        if ($k === 'v1' && $v !== null && $v !== '') $signatures[] = $v;
    }
    if ($timestamp === null || count($signatures) === 0) {
        return ['ok' => false, 'error' => 'invalid_header'];
    }
    if (abs(time() - $timestamp) > $toleranceSec) {
        return ['ok' => false, 'error' => 'timestamp_outside_tolerance'];
    }
    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    $matched = false;
    foreach ($signatures as $sig) {
        if (hash_equals($expected, $sig)) $matched = true;
    }
    if (!$matched) {
        return ['ok' => false, 'error' => 'signature_mismatch'];
    }
    return ['ok' => true, 'body' => json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR)];
}

// Read php://input once; header: $_SERVER['HTTP_X_MIA_SIGNATURE'] ?? null

Retries and idempotency

  • Mia attempts up to 4 deliveries per event: first immediately, then after 1 min, 5 min, 30 min.
  • When one delivery uses up all its attempts, the webhook is disabled automatically and we email you. Failed deliveries stay in the dashboard log (last 50); after you fix the URL, re-enable the webhook and press “Redeliver”.
  • Each request times out after 10 seconds, redirects are not followed, and only 2xx responses count as success.
  • Delivery is at-least-once: a retry can arrive after newer events, so do not assume any order. Sort by created_at and process each id once.

API version and changelog

  • The current payload version is 2026-11-03 (the api_version field).
  • When we add events we document them here and in the registry — we do not change existing shapes without a new version.
  • Check this page periodically or follow the product changelog.

Zapier (Webhooks by Zapier)

  • Create a new Zap → trigger Webhooks by Zapier → Catch Hook.
  • Copy the generated URL into Mia as your webhook endpoint; pick events (e.g. order.completed).
  • Send a test event from the dashboard or trigger a real action; Zapier should capture the payload.
  • Example: New order → Google Sheets — map data.product_title, data.amount_cents, data.buyer_email.
  • Example: New contact → CRM — use contact.created and map data.email and data.name when present.

Make (Custom webhook)

  • New scenario → Webhooks → Custom webhook → copy the address.
  • Add the address in Mia, choose events, save the secret if you verify HMAC in Make (HTTP module + HMAC function).
  • Run the scenario once; Make waits for the first POST.
  • After the first event, map fields from data to Sheets, Airtable, or email.

Events and sample payloads

order.completed

Order completed — A creator sale was paid. Emitted once when the order row is inserted.

FieldTypePersonal dataDescription
order_iduuidNoOrder id
product_iduuidNoProduct id
product_titlestringNoProduct title at purchase
amount_centsintegerNoCharge amount in minor units (bani or cents)
currencyenumNoISO 4217 currency
statusenumNoOrder status
buyer_emailemail | nullYesBuyer email. Null when checkout collected none.

Sample payload

{
  "id": "6c9e923c-6547-5a9c-bfbe-7d83e5e19b54",
  "type": "order.completed",
  "api_version": "2026-11-03",
  "created_at": "2026-11-03T12:00:00.000Z",
  "store_id": "11111111-1111-4111-8111-111111111111",
  "data": {
    "order_id": "22222222-2222-4222-8222-222222222222",
    "product_id": "33333333-3333-4333-8333-333333333333",
    "product_title": "Ghid de start",
    "amount_cents": 4500,
    "currency": "RON",
    "status": "paid",
    "buyer_email": "buyer@example.com"
  }
}

order.refunded

Order refunded — A paid order was refunded in full. Emitted once on the paid → refunded transition.

FieldTypePersonal dataDescription
order_iduuidNoOrder id
product_iduuidNoProduct id
product_titlestringNoProduct title at purchase
amount_centsintegerNoCharge amount in minor units (bani or cents)
currencyenumNoISO 4217 currency
statusenumNoOrder status
buyer_emailemail | nullYesBuyer email. Null when checkout collected none.

Sample payload

{
  "id": "2578302e-094d-579b-a553-6af647838d02",
  "type": "order.refunded",
  "api_version": "2026-11-03",
  "created_at": "2026-11-03T12:00:00.000Z",
  "store_id": "11111111-1111-4111-8111-111111111111",
  "data": {
    "order_id": "22222222-2222-4222-8222-222222222222",
    "product_id": "33333333-3333-4333-8333-333333333333",
    "product_title": "Ghid de start",
    "amount_cents": 4500,
    "currency": "RON",
    "status": "refunded",
    "buyer_email": "buyer@example.com"
  }
}

contact.created

Contact created — A contact was first stored for this creator. Updates do not emit again.

FieldTypePersonal dataDescription
contact_iduuidNoContact id
emailemailYesContact email
namestring | nullYesContact name. Null when unknown.
sourceenumNoHow the contact was captured
localeenumNoContact locale

Sample payload

{
  "id": "1121e58f-1f8a-54f0-9ad9-3174aa5eb3e8",
  "type": "contact.created",
  "api_version": "2026-11-03",
  "created_at": "2026-11-03T12:00:00.000Z",
  "store_id": "11111111-1111-4111-8111-111111111111",
  "data": {
    "contact_id": "44444444-4444-4444-8444-444444444444",
    "email": "buyer@example.com",
    "name": "Sample Buyer",
    "source": "lead_magnet",
    "locale": "ro"
  }
}

contact.unsubscribed

Contact unsubscribed — A contact unsubscribed. Emitted once when unsubscribed_at is set.

FieldTypePersonal dataDescription
contact_iduuidNoContact id
emailemailYesContact email, so the receiver can suppress them
unsubscribed_atdatetimeNoWhen the unsubscribe was stored, ISO-8601

Sample payload

{
  "id": "a94e5322-2d16-5a73-b452-58dfd5e234ae",
  "type": "contact.unsubscribed",
  "api_version": "2026-11-03",
  "created_at": "2026-11-03T12:00:00.000Z",
  "store_id": "11111111-1111-4111-8111-111111111111",
  "data": {
    "contact_id": "44444444-4444-4444-8444-444444444444",
    "email": "buyer@example.com",
    "unsubscribed_at": "2026-11-03T12:00:00.000Z"
  }
}

membership.started

Membership started — A member subscription became active. One event per subscription id.

FieldTypePersonal dataDescription
subscription_iduuidNoMia member subscription id
membership_iduuidNoMembership id
product_iduuidNoProduct id
intervalenumNoBilling interval
statusenumNoSubscription status
buyer_emailemail | nullYesMember email. Null when checkout had no address.
current_period_enddatetime | nullNoEnd of the current paid period, ISO-8601

Sample payload

{
  "id": "fa277bf8-37fa-5470-8634-631284f9e8f1",
  "type": "membership.started",
  "api_version": "2026-11-03",
  "created_at": "2026-11-03T12:00:00.000Z",
  "store_id": "11111111-1111-4111-8111-111111111111",
  "data": {
    "subscription_id": "55555555-5555-4555-8555-555555555555",
    "membership_id": "66666666-6666-4666-8666-666666666666",
    "product_id": "33333333-3333-4333-8333-333333333333",
    "interval": "monthly",
    "status": "active",
    "buyer_email": "member@example.com",
    "current_period_end": "2026-12-03T12:00:00.000Z"
  }
}

membership.cancelled

Membership cancelled — A member subscription was canceled. Period-end scheduling does not emit this.

FieldTypePersonal dataDescription
subscription_iduuidNoMia member subscription id
membership_iduuidNoMembership id
product_iduuidNoProduct id
intervalenumNoBilling interval
statusenumNoSubscription status
buyer_emailemail | nullYesMember email. Null when checkout had no address.
current_period_enddatetime | nullNoEnd of the current paid period, ISO-8601

Sample payload

{
  "id": "fccac8ad-d4fa-5a9c-8999-eac07199cee0",
  "type": "membership.cancelled",
  "api_version": "2026-11-03",
  "created_at": "2026-11-03T12:00:00.000Z",
  "store_id": "11111111-1111-4111-8111-111111111111",
  "data": {
    "subscription_id": "55555555-5555-4555-8555-555555555555",
    "membership_id": "66666666-6666-4666-8666-666666666666",
    "product_id": "33333333-3333-4333-8333-333333333333",
    "interval": "monthly",
    "status": "canceled",
    "buyer_email": "member@example.com",
    "current_period_end": "2026-12-03T12:00:00.000Z"
  }
}

booking.confirmed

Booking confirmed — A held slot was confirmed after payment.

FieldTypePersonal dataDescription
booking_iduuidNoBooking id
product_iduuidNoBooking product id
order_iduuid | nullNoPaid order id. Null when the booking has no order yet.
starts_atdatetimeNoSlot start, ISO-8601
ends_atdatetimeNoSlot end, ISO-8601
timezonestringNoBuyer timezone (IANA)
statusenumNoBooking status
buyer_emailemail | nullYesBuyer email. Null when unknown.

Sample payload

{
  "id": "14bed3db-2b9e-56ba-8f50-113ba3e20bac",
  "type": "booking.confirmed",
  "api_version": "2026-11-03",
  "created_at": "2026-11-03T12:00:00.000Z",
  "store_id": "11111111-1111-4111-8111-111111111111",
  "data": {
    "booking_id": "77777777-7777-4777-8777-777777777777",
    "product_id": "33333333-3333-4333-8333-333333333333",
    "order_id": "22222222-2222-4222-8222-222222222222",
    "starts_at": "2026-11-10T09:00:00.000Z",
    "ends_at": "2026-11-10T10:00:00.000Z",
    "timezone": "Europe/Bucharest",
    "status": "confirmed",
    "buyer_email": "buyer@example.com"
  }
}

booking.cancelled

Booking cancelled — A pending or confirmed booking was cancelled.

FieldTypePersonal dataDescription
booking_iduuidNoBooking id
product_iduuidNoBooking product id
order_iduuid | nullNoPaid order id. Null when the booking has no order yet.
starts_atdatetimeNoSlot start, ISO-8601
ends_atdatetimeNoSlot end, ISO-8601
timezonestringNoBuyer timezone (IANA)
statusenumNoBooking status
buyer_emailemail | nullYesBuyer email. Null when unknown.

Sample payload

{
  "id": "49171d11-fe43-579d-ab90-e7af37e265a4",
  "type": "booking.cancelled",
  "api_version": "2026-11-03",
  "created_at": "2026-11-03T12:00:00.000Z",
  "store_id": "11111111-1111-4111-8111-111111111111",
  "data": {
    "booking_id": "77777777-7777-4777-8777-777777777777",
    "product_id": "33333333-3333-4333-8333-333333333333",
    "order_id": "22222222-2222-4222-8222-222222222222",
    "starts_at": "2026-11-10T09:00:00.000Z",
    "ends_at": "2026-11-10T10:00:00.000Z",
    "timezone": "Europe/Bucharest",
    "status": "cancelled",
    "buyer_email": "buyer@example.com"
  }
}

Troubleshooting

My server returns 401/403 but Mia shows failure
Your endpoint must return 2xx without redirects. Check TLS, firewall, and that you do not require auth before signature verification.
Signature does not match
Use the raw body. Any JSON.parse plus JSON.stringify changes key order and breaks the HMAC.
I see the same event multiple times
Expected on retries. Dedupe on the envelope id.
Zapier receives nothing
Catch Hook URLs must be public HTTPS. Try “Send test” in Mia and confirm the webhook is active.
Buyer email is missing
Some checkouts skip email; the field may be null as documented in the schema.

If anything in this guide is unclear, email support@mia.store.