Webhooks

Receive real-time notifications when events happen in a store.

Overview

Webhooks allow your application to receive HTTP POST notifications when events occur in a Whatalo store. Instead of polling the API, your server receives data automatically.

Before you begin: managing webhooks requires an API key with the read:webhooks and write:webhooks scopes. When the merchant creates the key in Settings > Developer > API Keys, they select the Automatizaciones permission group (which contains both scopes). A key missing them receives a 403 SCOPE_INSUFFICIENT response. Once the key has the scopes, your integration registers webhooks itself through POST /v1/webhooks — no manual webhook setup in the dashboard is required.

How Webhooks Work

1. An event occurs (e.g., new order placed)
2. Whatalo sends a POST request to your registered URL
3. Your server processes the payload and returns 2xx
4. If delivery fails, Whatalo retries with exponential backoff

Managing Webhook Endpoints

List Webhooks

GET /v1/webhooks

Required scope: read:webhooks

Create Webhook

POST /v1/webhooks
{
  "url": "https://your-server.com/webhooks/whatalo",
  "events": ["order.created", "order.updated"]
}
FieldTypeRequiredDescription
urlstringYesHTTPS endpoint URL (must be HTTPS)
eventsstring[]YesEvent types to subscribe to
secretstringNoCustom signing secret (auto-generated if omitted)

Webhook URLs must use HTTPS. HTTP endpoints are rejected.

Required scope: write:webhooks

Update Webhook

PATCH /v1/webhooks/:id

Required scope: write:webhooks

Delete Webhook

DELETE /v1/webhooks/:id

Required scope: write:webhooks


Event Types

EventTrigger
order.createdNew order placed
order.updatedOrder status or data changed
order.cancelledOrder cancelled
order.completedOrder completed
product.createdNew product created
product.updatedProduct data changed
product.deletedProduct deleted
customer.createdNew customer registered
customer.updatedCustomer data changed
checkout.completedCheckout completed
checkout.abandonedCheckout marked abandoned after inactivity

Webhook Payload

Webhook payloads are JSON objects. The X-Webhook-Event header identifies the event type, and the body contains the event-specific payload.

For order, customer, and product business events, event_id is the public identifier of the affected entity. It matches order.id, customer.id, or product.id in the same payload. Use X-Webhook-Id for per-delivery idempotency; use X-Webhook-Event plus event_id for business-level deduplication.

order.created

{
  "event_id": "ord_abc123",
  "occurred_at": "2026-03-01T15:00:00.000Z",
  "order": {
    "id": "ord_abc123",
    "order_number": 1042,
    "status": "pending",
    "payment_status": "pending",
    "payment_method": "cash_on_delivery",
    "total": 8997,
    "subtotal": 8997,
    "shipping": 0,
    "discount": 0,
    "coupon_code": null,
    "free_shipping": false,
    "currency": "DOP",
    "created_at": "2026-03-01T15:00:00.000Z",
    "custom_fields": [],
    "shipping_address": {
      "name": "Ana Perez",
      "phone": "8090000000",
      "address": "Calle 1 #2",
      "city": "Santo Domingo",
      "province": "Distrito Nacional",
      "country": "República Dominicana",
      "postal_code": "10101",
      "notes": null
    },
    "items": [
      {
        "product_id": "prd_abc123",
        "product_name": "Starter Kit",
        "variant_name": "Color: Rojo",
        "quantity": 2,
        "unit_price": 4498.5,
        "total_price": 8997
      }
    ]
  },
  "customer": {
    "id": "cus_abc123",
    "name": "Ana Perez",
    "email": "[email protected]",
    "phone": "8090000000"
  },
  "store": {
    "id": "sto_abc123",
    "name": "Mi Tienda",
    "timezone": "America/Santo_Domingo"
  }
}

When a checkout includes service-style order bumps that are not persisted as order line items, the webhook also includes order.order_bumps with only those non-line bumps. Linked-product bumps are already represented in order.items and are not duplicated there.

order.updated

Order update payloads can be partial. For status changes, the body includes only the fields that changed:

{
  "event_id": "ord_abc123",
  "occurred_at": "2026-03-01T15:05:00.000Z",
  "order": {
    "id": "ord_abc123",
    "status": "completed"
  },
  "store": {
    "id": "sto_abc123",
    "timezone": "America/Santo_Domingo"
  }
}

Payment updates use the same partial shape with order.payment_status.

When the order's data changes without a status change — for example, when its line items are edited — the body omits both status and payment_status and carries only the order id, occurred_at, and the store. Treat this as a "data changed" signal and re-fetch the full order via GET /v1/orders/{id}:

{
  "event_id": "ord_abc123",
  "occurred_at": "2026-03-01T15:05:00.000Z",
  "order": {
    "id": "ord_abc123"
  },
  "store": {
    "id": "sto_abc123",
    "timezone": "America/Santo_Domingo"
  }
}

Product Events

product.created and product.updated use this shape:

{
  "event_id": "prd_abc123",
  "occurred_at": "2026-03-01T15:00:00.000Z",
  "product": {
    "id": "prd_abc123",
    "name": "Starter Kit",
    "slug": "starter-kit",
    "price": 2500,
    "product_status": "active",
    "archived_at": null,
    "main_image": "https://cdn.example.test/product.jpg"
  },
  "store": {
    "id": "sto_abc123",
    "timezone": "America/Santo_Domingo"
  }
}

product.deleted includes the deleted product identifier:

{
  "event_id": "prd_abc123",
  "occurred_at": "2026-03-01T15:00:00.000Z",
  "product": {
    "id": "prd_abc123"
  },
  "store": {
    "id": "sto_abc123",
    "timezone": "America/Santo_Domingo"
  }
}

Customer Events

customer.created and customer.updated use this shape:

{
  "event_id": "cus_abc123",
  "occurred_at": "2026-03-01T15:00:00.000Z",
  "customer": {
    "id": "cus_abc123",
    "name": "Ana Perez",
    "email": "[email protected]",
    "phone": "8090000000"
  },
  "store": {
    "id": "sto_abc123",
    "name": "Mi Tienda",
    "timezone": "America/Santo_Domingo"
  }
}

checkout.abandoned

Fires when a checkout draft is marked abandoned after a period of inactivity (a background job evaluates drafts periodically). Use checkout.recovery_url to bring the shopper back to their cart.

Premium feature: this event requires the store plan to include Abandoned Checkouts. Subscribing without it returns 403 FEATURE_ACCESS_DENIED, and deliveries pause automatically while the store is on a plan without the feature (the plan is re-checked on every firing). Abandoned checkouts can also be read on demand via GET /v1/checkout-drafts (scope read:checkout_drafts, same plan requirement).

The event_id is the abandoned checkout's public identifier and matches checkout.id. Unlike order events, customer has no id — an abandoned draft is captured before a customer record exists, so it carries only the contact fields (nullable). Either email or phone is always present.

{
  "event_id": "chk_abc123",
  "occurred_at": "2026-03-01T15:30:00.000Z",
  "checkout": {
    "id": "chk_abc123",
    "status": "abandoned",
    "total": 8997,
    "subtotal": 8997,
    "shipping": 0,
    "discount": 0,
    "currency": "DOP",
    "recovery_url": "https://mystore.example/r/RECOVERYTOKEN",
    "created_at": "2026-03-01T15:00:00.000Z",
    "abandoned_at": "2026-03-01T15:30:00.000Z",
    "shipping_address": {
      "name": "Ana Perez",
      "phone": "8090000000",
      "address": "Calle 1 #2",
      "city": "Santo Domingo",
      "province": "Distrito Nacional",
      "country": "República Dominicana",
      "postal_code": "10101",
      "notes": null
    },
    "items": [
      {
        "product_id": "prd_abc123",
        "product_name": "Starter Kit",
        "variant_name": "Color: Rojo",
        "quantity": 2,
        "unit_price": 4498.5,
        "total_price": 8997
      }
    ]
  },
  "customer": {
    "name": "Ana Perez",
    "email": "[email protected]",
    "phone": "8090000000"
  },
  "store": {
    "id": "sto_abc123",
    "name": "Mi Tienda",
    "timezone": "America/Santo_Domingo"
  }
}

When the abandoned draft captured order bumps, the webhook also includes checkout.order_bumps with { total, items: [{ bump_id, title, price }] }. The key is omitted when there were none.

Monetary fields such as total, subtotal, shipping, discount, and price are decimal major units, not minor units or cents. Example: 1500.50 means 1500.50 in the payload currency.


Webhook Security

Every webhook delivery includes these headers:

HeaderDescription
X-Webhook-IdUnique delivery ID. Store this value for idempotency.
X-Webhook-TimestampUnix epoch timestamp in seconds.
X-Webhook-SignatureHMAC-SHA256 hex digest.
X-Webhook-EventEvent name, such as order.created.

The signature is computed as:

HMAC-SHA256(secret, `${timestamp}.${rawBody}`) -> hex

Use the raw request body exactly as received, before JSON parsing. Reject timestamps older than 300 seconds to reduce replay risk.

For idempotency, store X-Webhook-Id and skip duplicate deliveries. For business-level deduplication, combine X-Webhook-Event with the body event_id, which is the public entity ID for order, customer, and product events.

Verification Example

Node.js
import crypto from "crypto";

function verifyWebhookSignature(rawBody, headers, secret) {
  const timestamp = headers["x-webhook-timestamp"];
  const signature = headers["x-webhook-signature"];

  if (!timestamp || !signature || !/^[a-f0-9]{64}$/i.test(signature)) return false;

  const timestampValue = Number(timestamp);
  if (!Number.isInteger(timestampValue)) return false;

  const ageSeconds = Math.abs(Math.floor(Date.now() / 1000) - timestampValue);
  if (ageSeconds > 300) return false;

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

  try {
    return crypto.timingSafeEqual(
      Buffer.from(signature, "hex"),
      Buffer.from(expected, "hex")
    );
  } catch {
    return false;
  }
}

Retry Policy

If your endpoint returns a non-2xx status code, Whatalo retries with exponential backoff:

AttemptDelay before attempt
2nd attempt1 second
3rd attempt2 seconds

4xx responses are treated as client errors and are not retried. 5xx responses and network errors are retried up to 3 total attempts.

On this page