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
- List Webhook Endpoints -
GET /api/v1/webhook-endpoints/ - Create Webhook Endpoint -
POST /api/v1/webhook-endpoints/ - Get Webhook Endpoint -
GET /api/v1/webhook-endpoints/{endpoint_id}/ - Update Webhook Endpoint -
PUT /api/v1/webhook-endpoints/{endpoint_id}/ - Delete Webhook Endpoint -
DELETE /api/v1/webhook-endpoints/{endpoint_id}/ - Rotate Secret -
POST /api/v1/webhook-endpoints/{endpoint_id}/rotate-secret/ - Send Ping -
POST /api/v1/webhook-endpoints/{endpoint_id}/ping/ - List Deliveries -
GET /api/v1/webhook-endpoints/{endpoint_id}/deliveries/ - Redeliver -
POST /api/v1/webhook-endpoints/{endpoint_id}/deliveries/{delivery_id}/redeliver/
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:
- Read the raw body as bytes, before parsing it as JSON. Parsing and re-encoding it changes the bytes and breaks the signature.
- Check that
Easel-Webhook-Timestampis 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. - Compute the signature and compare it, in constant time, with each
v1entry inEasel-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 |