Webhook-uri pentru dezvoltatori

Primești notificări POST semnate când se întâmplă ceva în magazinul tău — comandă plătită, contact nou, programare confirmată. Poți lega Zapier, Make sau propriul server.

Înapoi la centrul de ajutor · Deschide setările de webhook în dashboard

Configurare rapidă

  • În dashboard mergi la Integrări → Webhook-uri, adaugă URL-ul tău HTTPS și bifează evenimentele dorite.
  • Copiază secretul imediat — îl vezi o singură dată. Îl folosești ca cheie HMAC SHA-256. Evenimentul „Test” are tipul webhook.test și același format de mesaj.
  • Răspunde cu HTTP 2xx în câteva secunde. Dacă endpoint-ul tău e lent, pune evenimentul într-o coadă și răspunde repede.

Formatul mesajului

  • Fiecare livrare e un JSON cu câmpurile id, type, api_version, created_at, store_id și data.
  • Câmpurile din data sunt plate (fără obiecte imbricate) ca să le mapezi ușor în Zapier sau Make.
  • id e stabil pentru aceeași acțiune — folosește-l ca să ignori duplicatele când retrimitem livrarea.

X-Mia-Signature · API 2026-11-03 · toleranță 300s

Verifică semnătura

  • Header-ul de semnătură conține timestamp-ul și HMAC-ul versiunii v1: t=<unix>,v1=<hex>.
  • Mesajul semnat este {timestamp}.{rawBody} — exact octeții primiți, fără spații în plus.
  • Respinge cereri cu timestamp mai vechi de toleranța afișată mai jos (protecție replay).
  • După ce rotești secretul, vechiul secret mai semnează 24 de ore. În acest interval header-ul are două valori v1; acceptă cererea dacă oricare se potrivește.

Folosește corpul brut (raw body), nu JSON re-serializat.

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

Reîncercări și idempotență

  • Mia încearcă până la 4 livrări per eveniment: prima imediat, apoi după 1 min, 5 min, 30 min.
  • După ce o livrare își epuizează toate încercările, webhook-ul se dezactivează automat și îți trimitem un email. Livrările eșuate rămân în jurnalul din dashboard (ultimele 50); după ce repari URL-ul, reactivează webhook-ul și apasă „Retrimite”.
  • Cererea expiră după 10 secunde, redirect-urile nu sunt urmate, iar numai răspunsurile 2xx contează ca succes.
  • Livrarea este „cel puțin o dată”: o reîncercare poate ajunge după evenimente mai noi, deci nu presupune nicio ordine. Ordonează după created_at și procesează o singură dată fiecare id.

Versiune API și changelog

  • Versiunea curentă a payload-ului este 2026-11-03 (câmp api_version).
  • Când adăugăm evenimente noi, le documentăm aici și în registry — nu schimbăm tipurile existente fără versiune nouă.
  • Abonează-te la changelog-ul produsului sau revino periodic la această pagină.

Zapier (Webhooks by Zapier)

  • Creează un Zap nou → trigger Webhooks by Zapier → Catch Hook.
  • Copiază URL-ul generat și lipește-l în Mia ca endpoint webhook; selectează evenimentele (ex. order.completed).
  • Trimite un eveniment de test din dashboard sau fă o acțiune reală; Zapier ar trebui să prindă payload-ul.
  • Exemplu: Comandă nouă → Google Sheets — mapează data.product_title, data.amount_cents, data.buyer_email.
  • Exemplu: Contact nou → CRM — folosește contact.created și mapează data.email plus data.name dacă există.

Make (Custom webhook)

  • Scenario nou → modul Webhooks → Custom webhook → copiază adresa.
  • Adaugă adresa în Mia, alege evenimentele, salvează secretul pentru verificare (opțional în Make cu modul HTTP + funcție HMAC).
  • Rulează scenario-ul o dată; Make așteaptă primul POST.
  • După primul eveniment, mapează câmpurile din data către Sheets, Airtable sau email.

Evenimente și payload-uri

order.completed

Comandă finalizată — A creator sale was paid. Emitted once when the order row is inserted.

CâmpTipDate personaleDescriere
order_iduuidNuOrder id
product_iduuidNuProduct id
product_titlestringNuProduct title at purchase
amount_centsintegerNuCharge amount in minor units (bani or cents)
currencyenumNuISO 4217 currency
statusenumNuOrder status
buyer_emailemail | nullDaBuyer email. Null when checkout collected none.

Exemplu 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

Comandă rambursată — A paid order was refunded in full. Emitted once on the paid → refunded transition.

CâmpTipDate personaleDescriere
order_iduuidNuOrder id
product_iduuidNuProduct id
product_titlestringNuProduct title at purchase
amount_centsintegerNuCharge amount in minor units (bani or cents)
currencyenumNuISO 4217 currency
statusenumNuOrder status
buyer_emailemail | nullDaBuyer email. Null when checkout collected none.

Exemplu 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 nou — A contact was first stored for this creator. Updates do not emit again.

CâmpTipDate personaleDescriere
contact_iduuidNuContact id
emailemailDaContact email
namestring | nullDaContact name. Null when unknown.
sourceenumNuHow the contact was captured
localeenumNuContact locale

Exemplu 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

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

CâmpTipDate personaleDescriere
contact_iduuidNuContact id
emailemailDaContact email, so the receiver can suppress them
unsubscribed_atdatetimeNuWhen the unsubscribe was stored, ISO-8601

Exemplu 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

Abonament început — A member subscription became active. One event per subscription id.

CâmpTipDate personaleDescriere
subscription_iduuidNuMia member subscription id
membership_iduuidNuMembership id
product_iduuidNuProduct id
intervalenumNuBilling interval
statusenumNuSubscription status
buyer_emailemail | nullDaMember email. Null when checkout had no address.
current_period_enddatetime | nullNuEnd of the current paid period, ISO-8601

Exemplu 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

Abonament anulat — A member subscription was canceled. Period-end scheduling does not emit this.

CâmpTipDate personaleDescriere
subscription_iduuidNuMia member subscription id
membership_iduuidNuMembership id
product_iduuidNuProduct id
intervalenumNuBilling interval
statusenumNuSubscription status
buyer_emailemail | nullDaMember email. Null when checkout had no address.
current_period_enddatetime | nullNuEnd of the current paid period, ISO-8601

Exemplu 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

Programare confirmată — A held slot was confirmed after payment.

CâmpTipDate personaleDescriere
booking_iduuidNuBooking id
product_iduuidNuBooking product id
order_iduuid | nullNuPaid order id. Null when the booking has no order yet.
starts_atdatetimeNuSlot start, ISO-8601
ends_atdatetimeNuSlot end, ISO-8601
timezonestringNuBuyer timezone (IANA)
statusenumNuBooking status
buyer_emailemail | nullDaBuyer email. Null when unknown.

Exemplu 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

Programare anulată — A pending or confirmed booking was cancelled.

CâmpTipDate personaleDescriere
booking_iduuidNuBooking id
product_iduuidNuBooking product id
order_iduuid | nullNuPaid order id. Null when the booking has no order yet.
starts_atdatetimeNuSlot start, ISO-8601
ends_atdatetimeNuSlot end, ISO-8601
timezonestringNuBuyer timezone (IANA)
statusenumNuBooking status
buyer_emailemail | nullDaBuyer email. Null when unknown.

Exemplu 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"
  }
}

Depanare

Primesc 401/403 de la serverul meu, dar Mia arată eșec
Endpoint-ul trebuie să răspundă 2xx fără redirect. Verifică TLS, firewall și că nu ceri autentificare înainte de verificarea semnăturii.
Semnătura nu se potrivește
Folosește raw body. Orice JSON.parse + JSON.stringify schimbă ordinea cheilor și strică HMAC-ul.
Văd același eveniment de mai multe ori
Normal la reîncercări. Dedupe pe câmpul id din envelope.
Zapier nu primește nimic
URL-ul Catch Hook trebuie HTTPS public. Testează cu butonul „Trimite test” din Mia și verifică că webhook-ul e activ.
Lipsește email-ul cumpărătorului
Unele checkout-uri nu cer email; câmpul poate fi null — schema o permite.

Dacă ceva din ghid nu e clar, scrie-ne la support@mia.store.