Webhooks API

Webhooks tell your tools when something happens in your store, such as a new order, so they don’t have to poll the API. Register an endpoint, a URL of yours, and Easel sends it a signed POST for each event it subscribes to.

Delivery is at least once and unordered: an event can arrive twice, and a later event can arrive before an earlier one. Dedupe by the event’s id, and when you need the current state, fetch the resource from the API rather than trusting the order events arrived in.

Your endpoint should check the signature, answer with any 2xx status within 10 seconds, and do its work afterwards. Any other status, a redirect, or no answer in time is a failure, which Easel retries.

Endpoints


Events

Event Sent when data
order.created An order is placed: at checkout, from a draft order, or when a failed order is recovered The order
order.canceled An order is canceled The order
refund.created Money is refunded on an order, to its card, its gift card, or both, including refunds made in Stripe The refund
shipment.created A shipment is recorded for an order The shipment
ping You send a ping to test an endpoint The endpoint, without its secret

An endpoint subscribes to a list of events, or to every event with an empty list. Only the empty list takes in events added later, so choose it only when your receiver ignores types it doesn’t know. Every endpoint gets the pings sent to it, whatever it subscribes to.

A shipment is recorded unshipped, so shipment.created usually comes before it has tracking. Adding tracking later sends no event.

An endpoint is sent only what happens while it’s active, and nothing from before it was created.

Payload

Every delivery’s body is a JSON event:

{
  "id": "evt_d0b6fmv28q6vn14peun0",
  "type": "refund.created",
  "created_at": "2026-10-31T10:00:00Z",
  "store": "sto_d0b6fmv28q6vn14peun0",
  "order": "ord_d0b6fmv28q6vn14peun0",
  "data": {
    "id": "ref_d0b6fmv28q6vn14peun0",
    "number": 1,
    "order_id": "ord_d0b6fmv28q6vn14peun0",
    "amount": 500,
    "tax": 0,
    "taxes": [],
    "gift_card_amount": 0,
    "reason": "Damaged item",
    "restocks": [],
    "created_at": "2026-10-31T10:00:00Z"
  }
}
Field Type Description
id string Event public ID. A retry or redelivery of the event has the same ID, so dedupe on it
type string The event, from the table above
created_at string ISO 8601 timestamp of when the event happened
store string Public ID of the store the event is from
order string|null Public ID of the order the event is about, which fetching a refund or shipment needs; null for a ping
data object The resource the event is about, in the same shape the API returns it

data is rendered when the event is first delivered, so it shows the resource as it was then, which may be later than the event itself. Retries and redeliveries send the same body. To act on the latest state, fetch the resource.

An order’s billing and shipping_address are always null in webhooks, since an endpoint has no scopes of its own: the API shows them only to a key with customers:read. Fetch the order with such a key when you need them.

Headers

Header Description
Easel-Webhook-Id The event’s id
Easel-Webhook-Type The event’s type
Easel-Webhook-Timestamp When this attempt was signed, in Unix seconds
Easel-Webhook-Signature The attempt’s signature, such as v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Content-Type application/json
User-Agent Easel-Webhooks/1

Verifying Signatures

Each endpoint has a signing secret, which starts with whsec_ and is shown only when the endpoint is created or its secret rotated. Every attempt is signed with it, so your receiver can tell a delivery came from Easel and wasn’t changed or replayed.

The signature is the lowercase hex HMAC-SHA256 of the timestamp header, a ., and the raw request body, keyed with the whole secret, whsec_ prefix included:

v1 = hex(HMAC-SHA256(key = secret, message = timestamp + "." + body))

To verify a delivery:

  1. Read the raw body as bytes, before parsing it as JSON. Parsing and re-encoding it changes the bytes and breaks the signature.
  2. Check that Easel-Webhook-Timestamp is within 5 minutes of your clock, to refuse a delivery replayed later. Each attempt, retries included, is signed afresh, so a genuine delivery is never older than that.
  3. Compute the signature and compare it, in constant time, with each v1 entry in Easel-Webhook-Signature. The header can hold several entries separated by commas; accept the delivery when any matches, and skip entries of other schemes, which a later version may add.

Answer a delivery that fails these checks with a 4xx status, and don’t act on it.

Go

package receiver

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"io"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

const tolerance = 5 * time.Minute

// verify checks a delivery's signature with secret, the endpoint's whole
// signing secret, whsec_ prefix included.
func verify(secret string, h http.Header, body []byte, now time.Time) error {
	ts, err := strconv.ParseInt(h.Get("Easel-Webhook-Timestamp"), 10, 64)
	if err != nil {
		return errors.New("invalid timestamp")
	}
	age := now.Sub(time.Unix(ts, 0))
	if age > tolerance || age < -tolerance {
		return errors.New("timestamp outside tolerance")
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(strconv.FormatInt(ts, 10) + "."))
	mac.Write(body)
	want := []byte(hex.EncodeToString(mac.Sum(nil)))
	header := h.Get("Easel-Webhook-Signature")
	for entry := range strings.SplitSeq(header, ",") {
		scheme, sig, _ := strings.Cut(strings.TrimSpace(entry), "=")
		if scheme == "v1" && hmac.Equal([]byte(sig), want) {
			return nil
		}
	}
	return errors.New("no matching signature")
}

func handleWebhook(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "read failed", http.StatusBadRequest)
		return
	}
	secret := os.Getenv("EASEL_WEBHOOK_SECRET")
	if err := verify(secret, r.Header, body, time.Now()); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	// Dedupe by the event's ID, queue the work, and answer at once.
	w.WriteHeader(http.StatusNoContent)
}

Python

import hashlib
import hmac
import os
import time

from flask import Flask, request

TOLERANCE_SECONDS = 5 * 60


def verify(secret: str, headers, body: bytes, now: float) -> bool:
    """Report whether a delivery is signed with secret, the endpoint's
    whole signing secret, whsec_ prefix included."""
    raw = headers.get("Easel-Webhook-Timestamp", "")
    if not (raw.isascii() and raw.isdigit()):
        return False
    timestamp = int(raw)
    if abs(now - timestamp) > TOLERANCE_SECONDS:
        return False
    message = str(timestamp).encode() + b"." + body
    want = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
    for entry in headers.get("Easel-Webhook-Signature", "").split(","):
        scheme, _, signature = entry.strip().partition("=")
        if scheme == "v1" and hmac.compare_digest(
            signature.encode(), want.encode()
        ):
            return True
    return False


app = Flask(__name__)


@app.post("/easel")
def easel_webhook():
    body = request.get_data()  # the raw bytes, before any JSON parsing
    secret = os.environ["EASEL_WEBHOOK_SECRET"]
    if not verify(secret, request.headers, body, time.time()):
        return "invalid signature", 400
    # Dedupe by the event's ID, queue the work, and answer at once.
    return "", 204

Retries

A failed attempt is retried after a minute, then after a wait that doubles with each failure, up to 12 hours between attempts. Retries stop 72 hours after the event happened, when the delivery is marked failed; you can still redeliver it. Each attempt and its outcome show in the endpoint’s deliveries.

An endpoint is turned off only after its deliveries have kept failing, with no success, for 72 hours. Failures more than 24 hours apart start the count again, so a lone failure after a quiet spell doesn’t count the quiet days as failing. Once turned off, its active becomes false, disabled_at and disabled_reason say when and why, and the store’s owners are emailed. Fix the receiver, then turn the endpoint back on with Update Webhook Endpoint and redeliver what it missed.

A delivery still waiting for a retry when its endpoint is turned off fails with the error endpoint inactive, and can be redelivered once the endpoint is back on. Events that happen while an endpoint is off are never sent to it. A delivery whose signing secret Easel can’t read fails with the error secret unreadable and isn’t retried; rotate the secret and redeliver it. Neither failure counts toward turning the endpoint off.

Easel sends at most 2 deliveries to one endpoint at a time, so a backlog, such as the retries after an outage, arrives over a while rather than all at once.

Easel follows no redirects, so a 3xx is a failure: register the final URL. Only the response’s status matters; its body is ignored.

Allowed URLs

An endpoint’s URL must be an absolute https URL on the public internet, without credentials or a fragment, on port 443. Easel refuses a host that is, or resolves to, a private, loopback, link-local, or otherwise non-public address, and local names such as localhost or invoices.lan.

A self-hosted Easel server whose receivers share its network can list their addresses in EASEL_WEBHOOK_ALLOW_NETS, such as 192.168.1.20,10.0.0.0/24. Endpoints may then reach those addresses, and use local names and http on port 80, but http only to those addresses. Link-local addresses are never allowed. Never set it on a server others’ stores use.


List Webhook Endpoints

List the store’s endpoints, oldest first. A store has at most 10, so this endpoint is not paginated and has_more is always false.

Endpoint: GET /api/v1/webhook-endpoints/

Scope Required: webhooks:read

Request

curl -X GET "https://studio.easel.engineering/api/v1/webhook-endpoints/" \
  -H "Authorization: Bearer your_api_key_here"

Response

Status: 200 OK

{
  "data": [
    {
      "id": "whe_d0b6fmv28q6vn14peun0",
      "url": "https://invoices.example.com/easel",
      "description": "Invoicing",
      "events": ["order.created", "refund.created"],
      "active": true,
      "disabled_at": null,
      "disabled_reason": null,
      "created_at": "2026-10-31T09:00:00Z",
      "updated_at": "2026-10-31T09:00:00Z"
    }
  ],
  "has_more": false
}

Create Webhook Endpoint

Register an endpoint. The response holds its signing secret, which is shown only this once: store it with your receiver.

Subscribing to an event needs read access to the resource it carries, so a webhook can’t show a key more than it could read itself: orders:read for order.created and order.canceled, refunds:read for refund.created, and shipments:read for shipment.created. An empty events list subscribes to every event, and so needs all three. Updating an endpoint and rotating its secret check the same scopes.

Endpoint: POST /api/v1/webhook-endpoints/

Scope Required: webhooks:write, and read on the resources its events carry

Request Body

{
  "url": "https://invoices.example.com/easel",
  "description": "Invoicing",
  "events": ["order.created", "refund.created"]
}
Field Type Required Description
url string Yes Where to send events. See Allowed URLs. At most 2048 bytes
description string No A note on what the endpoint is for. At most 250 characters
events array No The events to send, such as ["order.created"]. Leave it out or empty for every event, including ones added later
active boolean No Whether to send events. Defaults to true; create it with false to ping your receiver before turning it on

Request

curl -X POST "https://studio.easel.engineering/api/v1/webhook-endpoints/" \
  -H "Authorization: Bearer your_api_key_here" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: unique-request-id-12345" \
  -d '{
    "url": "https://invoices.example.com/easel",
    "description": "Invoicing",
    "events": ["order.created", "refund.created"]
  }'

Response

Status: 201 Created

Returns the Webhook Endpoint Object with its secret:

{
  "id": "whe_d0b6fmv28q6vn14peun0",
  "url": "https://invoices.example.com/easel",
  "description": "Invoicing",
  "events": ["order.created", "refund.created"],
  "active": true,
  "disabled_at": null,
  "disabled_reason": null,
  "created_at": "2026-10-31T09:00:00Z",
  "updated_at": "2026-10-31T09:00:00Z",
  "secret": "whsec_3q2-7wWqgS0Ri6ZQx9mXv4Yk1bTnJc8LdPfA5hUeGzo"
}

Error Responses

Missing URL

Status: 400 Bad Request

{
  "error": "url is required",
  "code": "MISSING_FIELD"
}

Invalid field

Status: 400 Bad Request

{
  "error": "URL must use https",
  "code": "INVALID_FIELD"
}

The error message says which field is wrong:

Message Cause
URL must use https The URL’s scheme isn’t https
URL must be on the public internet, not a private or local address The host is a non-public address or a local name
URL must be an absolute https URL The URL is malformed, relative, too long, or has credentials or a fragment
Description must be at most 250 characters The description is too long
Events must be known event types events names an event not in the table. ping is sent on request and can’t be subscribed to

Missing scope for an event

Status: 403 Forbidden

{
  "error": "This API key requires refunds:read",
  "code": "INSUFFICIENT_PERMISSIONS"
}

Too many endpoints

Status: 409 Conflict

{
  "error": "A store can have at most 10 webhook endpoints",
  "code": "CONFLICT"
}

Webhooks unavailable

A server without a key to encrypt store secrets can’t keep signing secrets, so it offers no webhooks: it lists no endpoints, and creating, updating, or rotating one answers:

Status: 409 Conflict

{
  "error": "Webhooks are unavailable on this server",
  "code": "CONFLICT"
}

Get Webhook Endpoint

Endpoint: GET /api/v1/webhook-endpoints/{endpoint_id}/

Scope Required: webhooks:read

Request

curl -X GET "https://studio.easel.engineering/api/v1/webhook-endpoints/whe_d0b6fmv28q6vn14peun0/" \
  -H "Authorization: Bearer your_api_key_here"

Response

Status: 200 OK

Returns the Webhook Endpoint Object, without its secret.

Error Responses

Endpoint not found

Status: 404 Not Found

{
  "error": "Resource not found",
  "code": "NOT_FOUND"
}

Update Webhook Endpoint

Change the fields you send and keep the rest. Setting active to true turns on an endpoint Easel turned off, and clears its disabled_at and disabled_reason.

Every update checks the key’s scopes as creating an endpoint does, against the endpoint’s events after the change: those you send, or its current ones when you leave events out. That’s read on every resource they carry, or on all of them for an empty list. Pointing an endpoint elsewhere or turning it on sends its events there, so a key can’t change an endpoint whose events it couldn’t read itself.

Endpoint: PUT /api/v1/webhook-endpoints/{endpoint_id}/

Scope Required: webhooks:write, and read on the resources the endpoint’s events carry

Request Body

{
  "events": [],
  "active": true
}
Field Type Required Description
url string No Where to send events. See Allowed URLs
description string No A note on what the endpoint is for. Empty clears it
events array No The events to send. Empty for every event
active boolean No Whether to send events

Request

curl -X PUT "https://studio.easel.engineering/api/v1/webhook-endpoints/whe_d0b6fmv28q6vn14peun0/" \
  -H "Authorization: Bearer your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"active": true}'

Response

Status: 200 OK

Returns the Webhook Endpoint Object, without its secret.

Error Responses

The same 400 and 409 errors as creating an endpoint, and 404 Not Found with NOT_FOUND for an endpoint that doesn’t exist.

Missing scope for an event

The key can’t read a resource the endpoint’s events carry, after the change. The message names the scope it lacks.

Status: 403 Forbidden

{
  "error": "This API key requires refunds:read",
  "code": "INSUFFICIENT_PERMISSIONS"
}

Delete Webhook Endpoint

Delete an endpoint and its delivery log. Deliveries waiting for a retry are never sent.

Endpoint: DELETE /api/v1/webhook-endpoints/{endpoint_id}/

Scope Required: webhooks:write

Request

curl -X DELETE "https://studio.easel.engineering/api/v1/webhook-endpoints/whe_d0b6fmv28q6vn14peun0/" \
  -H "Authorization: Bearer your_api_key_here"

Response

Status: 204 No Content

Error Responses

Endpoint not found

Status: 404 Not Found

{
  "error": "Resource not found",
  "code": "NOT_FOUND"
}

Rotate Secret

Give an endpoint a new signing secret, returned this once. The old secret stops working at once: every attempt from then on, retries of earlier events included, is signed with the new one. Update your receiver right away; deliveries that fail in the meantime are retried.

Since the response holds the secret, rotating it checks the key’s scopes against the endpoint’s events, as creating an endpoint does.

Endpoint: POST /api/v1/webhook-endpoints/{endpoint_id}/rotate-secret/

Scope Required: webhooks:write, and read on the resources the endpoint’s events carry

Request

curl -X POST "https://studio.easel.engineering/api/v1/webhook-endpoints/whe_d0b6fmv28q6vn14peun0/rotate-secret/" \
  -H "Authorization: Bearer your_api_key_here"

Response

Status: 200 OK

Returns the Webhook Endpoint Object with its new secret, as Create Webhook Endpoint does.

Error Responses

404 Not Found with NOT_FOUND for an endpoint that doesn’t exist, and 409 Conflict when webhooks are unavailable.

Missing scope for an event

The key can’t read a resource the endpoint’s events carry. The message names the scope it lacks.

Status: 403 Forbidden

{
  "error": "This API key requires shipments:read",
  "code": "INSUFFICIENT_PERMISSIONS"
}

Send Ping

Send the endpoint a ping event, to check your receiver and its signature verification. A ping goes out within seconds, whether or not the endpoint is active, and shows in its deliveries. Its data is the endpoint, without its secret, and its order is null.

Endpoint: POST /api/v1/webhook-endpoints/{endpoint_id}/ping/

Scope Required: webhooks:write

Request

curl -X POST "https://studio.easel.engineering/api/v1/webhook-endpoints/whe_d0b6fmv28q6vn14peun0/ping/" \
  -H "Authorization: Bearer your_api_key_here"

Response

Status: 202 Accepted

The response body is empty.

Error Responses

Endpoint not found

Status: 404 Not Found

{
  "error": "Resource not found",
  "code": "NOT_FOUND"
}

List Deliveries

List an endpoint’s deliveries, newest first: one per event sent to it, with how its last attempt went. Deliveries are kept for 30 days, and at most the latest 1000 per endpoint.

Endpoint: GET /api/v1/webhook-endpoints/{endpoint_id}/deliveries/

Scope Required: webhooks:read

Query Parameters

Parameter Type Description
after string Cursor for pagination. Delivery public ID to return older deliveries after
before string Cursor for pagination. Delivery public ID to return newer deliveries before, for paging back
limit integer Number of deliveries to return (default 10, max 100)

Every page comes newest first, whether you page forward with after or back with before. after and before cannot be used together.

Request

curl -X GET "https://studio.easel.engineering/api/v1/webhook-endpoints/whe_d0b6fmv28q6vn14peun0/deliveries/?limit=2" \
  -H "Authorization: Bearer your_api_key_here"

Response

Status: 200 OK

{
  "data": [
    {
      "id": "whd_d0b6fmv28q6vn14peun1",
      "event_id": "evt_d0b6fmv28q6vn14peun1",
      "event_type": "refund.created",
      "status": "pending",
      "attempts": 2,
      "next_attempt_at": "2026-10-31T10:03:00Z",
      "last_attempt_at": "2026-10-31T10:01:00Z",
      "response_status": 502,
      "duration_ms": 1500,
      "error": "HTTP 502",
      "created_at": "2026-10-31T10:00:00Z"
    },
    {
      "id": "whd_d0b6fmv28q6vn14peun0",
      "event_id": "evt_d0b6fmv28q6vn14peun0",
      "event_type": "order.created",
      "status": "succeeded",
      "attempts": 1,
      "next_attempt_at": null,
      "last_attempt_at": "2026-10-31T09:30:00Z",
      "response_status": 204,
      "duration_ms": 87,
      "error": null,
      "created_at": "2026-10-31T09:30:00Z"
    }
  ],
  "has_more": true
}

Error Responses

Invalid query parameter

Status: 400 Bad Request

A malformed after or before, both at once, or an invalid limit returns INVALID_REQUEST_ERROR, with the offending parameter named in the error message:

{
  "error": "Invalid 'limit' parameter: cannot exceed 100",
  "code": "INVALID_REQUEST_ERROR"
}

Endpoint or cursor not found

An endpoint that doesn’t exist, or an after or before that names no delivery of the endpoint.

Status: 404 Not Found

{
  "error": "Resource not found",
  "code": "NOT_FOUND"
}

Redeliver

Try a failed delivery again now, with the same body, signed afresh. Within 72 hours of the event, a redelivery that fails is retried as before; past that, it gets this one attempt.

Endpoint: POST /api/v1/webhook-endpoints/{endpoint_id}/deliveries/{delivery_id}/redeliver/

Scope Required: webhooks:write

Request

curl -X POST "https://studio.easel.engineering/api/v1/webhook-endpoints/whe_d0b6fmv28q6vn14peun0/deliveries/whd_d0b6fmv28q6vn14peun1/redeliver/" \
  -H "Authorization: Bearer your_api_key_here"

Response

Status: 202 Accepted

The response body is empty.

Error Responses

Delivery not failed

A delivery that’s pending or succeeded can’t be redelivered.

Status: 409 Conflict

{
  "error": "Only a failed delivery can be redelivered",
  "code": "CONFLICT"
}

Endpoint or delivery not found

Status: 404 Not Found

{
  "error": "Resource not found",
  "code": "NOT_FOUND"
}

Webhook Endpoint Object

Field Type Description
id string Webhook endpoint public ID
url string Where events are sent
description string A note on what the endpoint is for, or empty
events array The events it’s sent; empty for every event, including ones added later
active boolean Whether events are sent to it
disabled_at string|null ISO 8601 timestamp of when Easel turned it off because its deliveries kept failing, until it’s turned on again
disabled_reason string|null Why Easel turned it off: failing
created_at string ISO 8601 timestamp
updated_at string ISO 8601 timestamp
secret string The signing secret, starting with whsec_. Only in the responses to Create Webhook Endpoint and Rotate Secret

Webhook Delivery Object

Field Type Description
id string Webhook delivery public ID
event_id string The event’s id, as its payload and Easel-Webhook-Id header give it
event_type string The event’s type
status string pending while it’s being delivered or retried, succeeded, or failed once retries stopped
attempts integer Attempts made so far
next_attempt_at string|null ISO 8601 timestamp of the next attempt while pending, else null
last_attempt_at string|null ISO 8601 timestamp of the last attempt, or null before the first
response_status integer|null The HTTP status your endpoint answered the last attempt with, or null when no response came, as on a timeout
duration_ms integer|null How long the last attempt took, in milliseconds
error string|null What went wrong with the last attempt, or null when it succeeded. See Delivery Errors
created_at string ISO 8601 timestamp

Delivery Errors

A delivery’s error is one of these fixed messages, never the underlying error, which could tell what’s on Easel’s network:

error Cause
HTTP <status>, such as HTTP 502 Your endpoint answered with a status outside 2xx, which is in response_status
address not allowed The URL is, or resolves to, an address webhooks may not reach. See Allowed URLs
could not connect The host couldn’t be resolved or connected to
timed out Your endpoint didn’t answer in time
TLS error The TLS handshake or certificate failed
request failed The request failed in another way
endpoint inactive The endpoint was turned off before the delivery was sent. Not retried
secret unreadable Easel couldn’t read the endpoint’s signing secret. Not retried