Guuds API
Everything you can do in your store’s admin you can also do from your own code. The API is the same /api/admin/ surface the admin uses, reached with a store API key instead of a sign-in.
Authentication
Every request sends a store API key as a Bearer token in the Authorization header. Keys start with gk_live_ and belong to one store: a request made with a key acts as that store’s owner, and only ever sees that store’s data.
Authorization: Bearer gk_live_AbCdEfGh...An owner or manager creates keys in Settings → API keys. The full key is shown once, when it is created — copy it then and keep it secret, like a password. Revoke a key there the moment it is no longer needed or may have leaked.
curl https://api.guuds.ai/api/admin/products \
-H "Authorization: Bearer gk_live_AbCdEfGh..."Scopes
Each key holds one or more scopes, picked when it is created. A scope grants one kind of access to the /api/admin/ paths below and everything beneath them: a .read scope allows GET, a .write scope allows the methods that change data. Any other admin path — settings, API keys, billing, themes — is refused to every key with a 403 scope_forbidden.
| Scope | What it allows | Paths |
|---|---|---|
products.read | Read products — list and fetch products.GET, HEAD |
|
products.write | Edit products — create, update and delete products.POST, PUT, PATCH, DELETE |
|
orders.read | Read orders — list and fetch orders.GET, HEAD |
|
orders.write | Edit orders — create, update and delete orders.POST, PUT, PATCH, DELETE |
|
customers.read | Read customers — list and fetch customers.GET, HEAD |
|
customers.write | Edit customers — reserved: customer profiles are written only by orders, so no endpoint needs this scope yet.POST, PUT, PATCH, DELETE |
|
discounts.read | Read discounts — list and fetch discounts.GET, HEAD |
|
discounts.write | Edit discounts — create, update and delete discounts.POST, PUT, PATCH, DELETE |
|
Endpoints
Every endpoint answers JSON with "ok": true on success. Lists take limit (up to 200) and offset; :id is the numeric id the list returns. Each endpoint names the scope the key must hold — call it without that scope and the answer is a 403 scope_forbidden.
Products and inventory
GET /api/admin/products
Required scope: products.read
List products, newest first. limit and offset page through them; subscription=1 keeps only products offered as a subscription.
Request
GET /api/admin/products?limit=50&offset=0
Authorization: Bearer gk_live_AbCdEfGh...Response · 200
{
"ok": true,
"products": [
{
"product_id": 42,
"title": "Linen apron",
"handle": "linen-apron",
"status": "active",
"vendor": "Studio North",
"product_type": "Kitchen",
"tags": "linen,gift",
"min_price": "38.00",
"max_price": "38.00",
"currency": "USD",
"total_inventory": 24,
"subscription_enabled": false,
"published_at": "2026-10-01T09:00:00.000Z",
"created_at": "2026-10-01T09:00:00.000Z",
"updated_at": "2026-10-07T14:12:00.000Z"
}
]
}GET /api/admin/products/:id
Required scope: products.read
Fetch one product with its rollups.
Request
GET /api/admin/products/:id
Authorization: Bearer gk_live_AbCdEfGh...Response · 200
{
"ok": true,
"product": {
"product_id": 42,
"title": "Linen apron",
"handle": "linen-apron",
"status": "active",
"vendor": "Studio North",
"product_type": "Kitchen",
"tags": "linen,gift",
"min_price": "38.00",
"max_price": "38.00",
"currency": "USD",
"total_inventory": 24,
"subscription_enabled": false,
"published_at": "2026-10-01T09:00:00.000Z",
"created_at": "2026-10-01T09:00:00.000Z",
"updated_at": "2026-10-07T14:12:00.000Z"
}
}POST /api/admin/products
Required scope: products.write
Create a product. Only title is required; with no variants it gets one default variant at price. status is draft unless you say active.
Request
POST /api/admin/products
Authorization: Bearer gk_live_AbCdEfGh...
Content-Type: application/json
{
"title": "Linen apron",
"status": "active",
"price": 38,
"vendor": "Studio North",
"tags": [
"linen",
"gift"
],
"variants": [
{
"title": "Natural",
"price": 38,
"sku": "APRON-NAT"
}
]
}Response · 201
{
"ok": true,
"product": {
"product_id": 42,
"title": "Linen apron",
"handle": "linen-apron",
"status": "active",
"vendor": "Studio North",
"product_type": "Kitchen",
"tags": "linen,gift",
"min_price": "38.00",
"max_price": "38.00",
"currency": "USD",
"total_inventory": 24,
"subscription_enabled": false,
"published_at": "2026-10-01T09:00:00.000Z",
"created_at": "2026-10-01T09:00:00.000Z",
"updated_at": "2026-10-07T14:12:00.000Z"
}
}PATCH /api/admin/products/:id
Required scope: products.write
Update a product; only the fields you send change.
Request
PATCH /api/admin/products/:id
Authorization: Bearer gk_live_AbCdEfGh...
Content-Type: application/json
{
"title": "Linen apron — natural",
"status": "active"
}Response · 200
{
"ok": true,
"product": {
"product_id": 42,
"title": "Linen apron — natural",
"handle": "linen-apron",
"status": "active",
"vendor": "Studio North",
"product_type": "Kitchen",
"tags": "linen,gift",
"min_price": "38.00",
"max_price": "38.00",
"currency": "USD",
"total_inventory": 24,
"subscription_enabled": false,
"published_at": "2026-10-01T09:00:00.000Z",
"created_at": "2026-10-01T09:00:00.000Z",
"updated_at": "2026-10-07T14:12:00.000Z"
}
}DELETE /api/admin/products/:id
Required scope: products.write
Archive a product. It leaves the storefront, but past orders that name it still resolve.
Request
DELETE /api/admin/products/:id
Authorization: Bearer gk_live_AbCdEfGh...Response · 200
{
"ok": true,
"archived": true,
"product": {
"product_id": 42,
"title": "Linen apron",
"handle": "linen-apron",
"status": "archived",
"vendor": "Studio North",
"product_type": "Kitchen",
"tags": "linen,gift",
"min_price": "38.00",
"max_price": "38.00",
"currency": "USD",
"total_inventory": 24,
"subscription_enabled": false,
"published_at": "2026-10-01T09:00:00.000Z",
"created_at": "2026-10-01T09:00:00.000Z",
"updated_at": "2026-10-07T14:12:00.000Z"
}
}GET /api/admin/inventory/low-stock
Required scope: products.read
Variants at or below their low-stock threshold, the furthest under first.
Request
GET /api/admin/inventory/low-stock?limit=50
Authorization: Bearer gk_live_AbCdEfGh...Response · 200
{
"ok": true,
"items": [
{
"product_variant_id": 88,
"product_id": 42,
"sku": "APRON-NAT",
"quantity": 2,
"low_stock_threshold": 5,
"low_stock": true,
"out_of_stock": false
}
],
"total": 1,
"limit": 50,
"offset": 0,
"truncated": false
}PUT /api/admin/variants/:id/inventory
Required scope: products.write
Set a variant’s counted quantity and/or low_stock_threshold; a field left out keeps its value. Restocking from zero notifies shoppers waiting on it.
Request
PUT /api/admin/variants/:id/inventory
Authorization: Bearer gk_live_AbCdEfGh...
Content-Type: application/json
{
"quantity": 24,
"low_stock_threshold": 5
}Response · 200
{
"ok": true,
"inventory": {
"product_variant_id": 88,
"product_id": 42,
"sku": "APRON-NAT",
"quantity": 24,
"low_stock_threshold": 5,
"low_stock": false,
"out_of_stock": false
},
"product": {
"product_id": 42,
"title": "Linen apron",
"handle": "linen-apron",
"status": "active",
"vendor": "Studio North",
"product_type": "Kitchen",
"tags": "linen,gift",
"min_price": "38.00",
"max_price": "38.00",
"currency": "USD",
"total_inventory": 24,
"subscription_enabled": false,
"published_at": "2026-10-01T09:00:00.000Z",
"created_at": "2026-10-01T09:00:00.000Z",
"updated_at": "2026-10-07T14:12:00.000Z"
},
"back_in_stock": null
}GET /api/admin/collections
Required scope: products.read
List collections.
Request
GET /api/admin/collections
Authorization: Bearer gk_live_AbCdEfGh...Response · 200
{
"ok": true,
"collections": [
{
"product_collection_id": 5,
"title": "Kitchen",
"handle": "kitchen",
"kind": "manual",
"published": true
}
]
}POST /api/admin/collections
Required scope: products.write
Create a collection.
Request
POST /api/admin/collections
Authorization: Bearer gk_live_AbCdEfGh...
Content-Type: application/json
{
"title": "Kitchen",
"kind": "manual",
"published": true
}Response · 201
{
"ok": true,
"collection": {
"product_collection_id": 5,
"title": "Kitchen",
"handle": "kitchen",
"kind": "manual",
"published": true
}
}Orders and fulfilments
GET /api/admin/orders
Required scope: orders.read
List orders with a real total. Filter with status (lifecycle, payment or fulfilment status), channel, or search with q (order number or email).
Request
GET /api/admin/orders?status=paid&limit=50
Authorization: Bearer gk_live_AbCdEfGh...Response · 200
{
"ok": true,
"orders": [
{
"order_id": 1007,
"name": "#1007",
"email": "[email protected]",
"customer_id": 311,
"status": "open",
"financial_status": "paid",
"fulfillment_status": "",
"total_price": "76.00",
"total_refunded": "0.00",
"currency": "USD",
"source_name": "web",
"created_at": "2026-10-07T11:58:00.000Z"
}
],
"total": 214,
"limit": 50,
"offset": 0,
"has_more": true
}GET /api/admin/orders/:id
Required scope: orders.read
Fetch one order with its line items, customer and totals.
Request
GET /api/admin/orders/:id
Authorization: Bearer gk_live_AbCdEfGh...Response · 200
{
"ok": true,
"order": {
"order_id": 1007,
"name": "#1007",
"email": "[email protected]",
"customer_id": 311,
"status": "open",
"financial_status": "paid",
"fulfillment_status": "",
"total_price": "76.00",
"total_refunded": "0.00",
"currency": "USD",
"source_name": "web",
"created_at": "2026-10-07T11:58:00.000Z"
},
"lines": [
{
"purchase_id": 5001,
"order_id": 1007,
"product_id": 42,
"product_variant_id": 88,
"title": "Linen apron",
"sku": "APRON-NAT",
"quantity": 2,
"price": "38.00",
"line_total": "76.00"
}
],
"customer": {
"customer_id": 311,
"email": "[email protected]",
"first_name": "Ana",
"last_name": "Silva",
"accepts_marketing": true,
"orders_count": 3,
"total_spent": "164.00",
"lifetime_value": "164.00",
"average_order_value": "54.67",
"last_order_id": 1007
},
"totals": {
"units": 2,
"lines": 1
}
}POST /api/admin/orders/:id/cancel
Required scope: orders.write
Cancel an unfulfilled order and put its stock back. reason is one of customer, fraud, inventory, declined, other. A 409 once the order is fulfilled. Money is not refunded — that is the refund endpoint.
Request
POST /api/admin/orders/:id/cancel
Authorization: Bearer gk_live_AbCdEfGh...
Content-Type: application/json
{
"reason": "customer",
"note": "Changed their mind"
}Response · 200
{
"ok": true,
"order": {
"order_id": 1007,
"name": "#1007",
"email": "[email protected]",
"customer_id": 311,
"status": "cancelled",
"financial_status": "paid",
"fulfillment_status": "",
"total_price": "76.00",
"total_refunded": "0.00",
"currency": "USD",
"source_name": "web",
"created_at": "2026-10-07T11:58:00.000Z",
"cancel_reason": "customer"
},
"inventory": [
{
"product_variant_id": 88,
"sku": "APRON-NAT",
"before": 22,
"after": 24,
"restored": 2
}
],
"lines": 1
}POST /api/admin/orders/:id/refund
Required scope: orders.write
Refund through the payment provider. Leave out amount to refund everything still refundable. reason is one of duplicate, fraudulent, requested_by_customer. Stock is not restocked.
Request
POST /api/admin/orders/:id/refund
Authorization: Bearer gk_live_AbCdEfGh...
Content-Type: application/json
{
"amount": 38,
"reason": "requested_by_customer"
}Response · 200
{
"ok": true,
"order": {
"order_id": 1007,
"name": "#1007",
"email": "[email protected]",
"customer_id": 311,
"status": "open",
"financial_status": "partially_refunded",
"fulfillment_status": "",
"total_price": "76.00",
"total_refunded": "38.00",
"currency": "USD",
"source_name": "web",
"created_at": "2026-10-07T11:58:00.000Z"
},
"refund": {
"stripe_refund_id": "re_3Q…",
"status": "succeeded",
"amount": "38.00",
"currency": "USD",
"reason": "requested_by_customer"
},
"total_refunded": "38.00",
"refundable_remaining": "38.00"
}GET /api/admin/orders/:id/fulfilments
Required scope: orders.read
The despatches already made for an order.
Request
GET /api/admin/orders/:id/fulfilments
Authorization: Bearer gk_live_AbCdEfGh...Response · 200
{
"ok": true,
"order_id": 1007,
"fulfillment_status": "fulfilled",
"fulfilments": [
{
"shipment_id": 610,
"order_id": 1007,
"carrier": "UPS",
"tracking_number": "1Z999AA10123456784",
"status": "shipped",
"shipped_at": "2026-10-08T10:00:00.000Z"
}
]
}POST /api/admin/orders/:id/fulfilments
Required scope: orders.write
Record one despatch. carrier is required; send lines to ship part of the order, or nothing to ship everything outstanding. The shopper is emailed once it is shipped.
Request
POST /api/admin/orders/:id/fulfilments
Authorization: Bearer gk_live_AbCdEfGh...
Content-Type: application/json
{
"carrier": "UPS",
"tracking_number": "1Z999AA10123456784",
"status": "shipped",
"lines": [
{
"purchase_id": 5001,
"quantity": 2
}
]
}Response · 201
{
"ok": true,
"fulfilment": {
"shipment_id": 610,
"order_id": 1007,
"carrier": "UPS",
"tracking_number": "1Z999AA10123456784",
"status": "shipped",
"shipped_at": "2026-10-08T10:00:00.000Z"
},
"fulfillment_status": "fulfilled",
"outstanding_units": 0,
"lines": [
{
"purchase_id": 5001,
"quantity": 2
}
],
"order_id": 1007,
"order_name": "#1007",
"notification": {
"results": [
{
"channel": "email",
"status": "sent"
}
]
}
}Customers
GET /api/admin/customers
Required scope: customers.read
List customers. Search with q (email, name or phone); sort by lifetime value, order count or most recent order.
Request
GET /api/admin/customers?q=ana&limit=50
Authorization: Bearer gk_live_AbCdEfGh...Response · 200
{
"ok": true,
"customers": [
{
"customer_id": 311,
"email": "[email protected]",
"first_name": "Ana",
"last_name": "Silva",
"accepts_marketing": true,
"orders_count": 3,
"total_spent": "164.00",
"lifetime_value": "164.00",
"average_order_value": "54.67",
"last_order_id": 1007,
"points": null
}
],
"total": 214,
"limit": 50,
"offset": 0,
"has_more": true
}GET /api/admin/customers/:id
Required scope: customers.read
Fetch one customer with their order history and figures recomputed from those orders.
Request
GET /api/admin/customers/:id
Authorization: Bearer gk_live_AbCdEfGh...Response · 200
{
"ok": true,
"customer": {
"customer_id": 311,
"email": "[email protected]",
"first_name": "Ana",
"last_name": "Silva",
"accepts_marketing": true,
"orders_count": 3,
"total_spent": "164.00",
"lifetime_value": "164.00",
"average_order_value": "54.67",
"last_order_id": 1007
},
"orders": [
{
"order_id": 1007,
"name": "#1007",
"total_price": "76.00",
"financial_status": "paid",
"created_at": "2026-10-07T11:58:00.000Z"
}
],
"stats": {
"orders_count": 3,
"lifetime_value": "164.00",
"average_order_value": "54.67"
},
"counters": {
"orders_count": 3,
"total_spent": "164.00",
"last_order_id": 1007
}
}Discounts
GET /api/admin/discounts
Required scope: discounts.read
List discount codes and automatic promotions. Filter with status or trigger (code, automatic).
Request
GET /api/admin/discounts?status=active
Authorization: Bearer gk_live_AbCdEfGh...Response · 200
{
"ok": true,
"discounts": [
{
"discount_id": 9,
"trigger": "code",
"code": "WELCOME10",
"title": "WELCOME10",
"kind": "percentage",
"scope": "order",
"value": "10.00",
"currency": "USD",
"min_subtotal": null,
"starts_at": "2026-10-08T00:00:00.000Z",
"ends_at": null,
"usage_limit": 500,
"usage_count": 0,
"once_per_customer": true,
"status": "active"
}
]
}GET /api/admin/discounts/:id
Required scope: discounts.read
Fetch one discount.
Request
GET /api/admin/discounts/:id
Authorization: Bearer gk_live_AbCdEfGh...Response · 200
{
"ok": true,
"discount": {
"discount_id": 9,
"trigger": "code",
"code": "WELCOME10",
"title": "WELCOME10",
"kind": "percentage",
"scope": "order",
"value": "10.00",
"currency": "USD",
"min_subtotal": null,
"starts_at": "2026-10-08T00:00:00.000Z",
"ends_at": null,
"usage_limit": 500,
"usage_count": 0,
"once_per_customer": true,
"status": "active"
}
}POST /api/admin/discounts
Required scope: discounts.write
Create a discount code (2–64 letters, digits, dashes or underscores) or, with trigger: "automatic" and a title, a promotion that applies by itself. A 409 if the code is taken.
Request
POST /api/admin/discounts
Authorization: Bearer gk_live_AbCdEfGh...
Content-Type: application/json
{
"code": "WELCOME10",
"kind": "percentage",
"value": 10,
"usage_limit": 500,
"once_per_customer": true
}Response · 201
{
"ok": true,
"discount": {
"discount_id": 9,
"trigger": "code",
"code": "WELCOME10",
"title": "WELCOME10",
"kind": "percentage",
"scope": "order",
"value": "10.00",
"currency": "USD",
"min_subtotal": null,
"starts_at": "2026-10-08T00:00:00.000Z",
"ends_at": null,
"usage_limit": 500,
"usage_count": 0,
"once_per_customer": true,
"status": "active"
}
}PATCH /api/admin/discounts/:id
Required scope: discounts.write
Update a discount; only the fields you send change, and changed names them.
Request
PATCH /api/admin/discounts/:id
Authorization: Bearer gk_live_AbCdEfGh...
Content-Type: application/json
{
"ends_at": "2026-12-31T23:59:59.000Z"
}Response · 200
{
"ok": true,
"changed": [
"ends_at"
],
"discount": {
"discount_id": 9,
"trigger": "code",
"code": "WELCOME10",
"title": "WELCOME10",
"kind": "percentage",
"scope": "order",
"value": "10.00",
"currency": "USD",
"min_subtotal": null,
"starts_at": "2026-10-08T00:00:00.000Z",
"ends_at": "2026-12-31T23:59:59.000Z",
"usage_limit": 500,
"usage_count": 0,
"once_per_customer": true,
"status": "active"
}
}DELETE /api/admin/discounts/:id
Required scope: discounts.write
Remove a discount: deleted if it was never used, otherwise disabled so orders that name it still resolve.
Request
DELETE /api/admin/discounts/:id
Authorization: Bearer gk_live_AbCdEfGh...Response · 200
{
"ok": true,
"disposition": "deleted",
"removed": {
"discount_id": 9,
"trigger": "code",
"code": "WELCOME10",
"title": "WELCOME10",
"kind": "percentage",
"scope": "order",
"value": "10.00",
"currency": "USD",
"min_subtotal": null,
"starts_at": "2026-10-08T00:00:00.000Z",
"ends_at": null,
"usage_limit": 500,
"usage_count": 0,
"once_per_customer": true,
"status": "active"
}
}Errors
Every error answers a non-2xx HTTP status with the same JSON shape: "ok": false, a machine-readable error code to branch on, and a readable message to log or show. Some errors add fields of their own, like retry_after on a 429. Branch on the status and error, never on the wording of message.
- 401
not_authenticated A 401 means the request had no API key, or the key was missing, malformed, unknown or revoked — check the Authorization header carries a current gk_live_ key, or create a new key in Settings.
{ "ok": false, "error": "not_authenticated", "message": "Sign in to continue" }- 403
scope_forbidden A 403 with code scope_forbidden means the key is valid but does not hold the scope this endpoint needs — the message names the missing scope; create a key with that scope in Settings and use it instead.
{ "ok": false, "error": "scope_forbidden", "message": "This API key does not have the \"products.write\" scope required for POST /api/admin/products; create a key with that scope in Settings to use this endpoint." }- 429
rate_limited A 429 with code rate_limited means too many requests came from your address in the current window — the request itself was fine, it was simply too soon. retry_after in the body (and the Retry-After header) is how many seconds until the window resets.
{ "ok": false, "error": "rate_limited", "message": "Too many requests. Try again in 42s.", "retry_after": 42 }
When scope_forbidden fires
The key is checked before the endpoint runs, so a 403 scope_forbidden never half-applies a change. It fires when the key lacks the scope named on the endpoint (a .read key calling a POST, PATCH or DELETE, or a key for another resource), and on every admin path no scope covers — settings, API keys, billing, themes — whatever scopes the key holds. Retrying will not help: create a key with the scope the message names in Settings → API keys.
Rate limits
Rate-limited endpoints count requests per client IP address in fixed windows. Each response from one carries headers telling you where you stand, and going over the limit answers 429 rate_limited until the window resets:
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
RateLimit-Limit: 5
RateLimit-Remaining: 0
RateLimit-Reset: 42
Retry-After: 42
{ "ok": false, "error": "rate_limited", "message": "Too many requests. Try again in 42s.", "retry_after": 42 }| Header | Meaning |
|---|---|
RateLimit-Limit | Requests allowed in the current window. |
RateLimit-Remaining | Requests left before the next one is refused. |
RateLimit-Reset | Seconds until the window resets and the count starts again. |
Retry-After | Only on a 429: seconds to wait before trying again (the same number as retry_after). |
- On a
429, wait theRetry-Afterseconds (orretry_afterfrom the body) before sending the request again — a retry inside the window is refused too and does not shorten it. - Watch
RateLimit-Remainingand slow down as it nears zero instead of running into the limit. - Retry automatically only on
429and5xx, with a growing, slightly random gap between attempts; a401or403will answer the same until you change the key.
Webhooks
A webhook tells your server the moment something happens in your store. Add an HTTPS endpoint in Settings → Webhooks and pick the events it receives; Guuds then POSTs a JSON body to it for each one, naming the event in the X-Guuds-Event header and in the body’s eventfield, with a created_at timestamp.
| Event | When it is sent |
|---|---|
order.paid | An order is paid — online checkout, a POS sale or a subscription renewal. Body carries the order and its source. |
order.fulfilled | An order is fulfilled or part-fulfilled. Body carries the order, fulfillment_status, outstanding_units, shipment_id and the lines shipped. |
product.updated | A product is edited in the admin. Body carries the product and the fields that changed. |
booking.created | A booking is confirmed — the order holding its slot is paid (Booking calendar add-on). Body carries the booking (id, product_id, starts_at, ends_at, timezone, status) and its order_id. |
booking.cancelled | A booking is cancelled in the admin (Booking calendar add-on). Body carries the booking, with status and cancelled_at, and its order_id. |
label.purchased | A shipping label is bought (Shipping labels add-on). Body carries the label (carrier, service, tracking_number, tracking_url, label_url, cost_cents, currency) and its order_id. |
POST /your/webhook/path HTTP/1.1
Content-Type: application/json
X-Guuds-Event: order.paid
X-Guuds-Signature: <64 hex characters: HMAC-SHA256 of the body>
{"event":"order.paid","created_at":"2026-10-07T12:00:00.000Z","order":{ ... },"source":"checkout"}Answer with any 2xx status to acknowledge a delivery. Anything else — an error status, a redirect, a timeout after 10 seconds — counts as a failure and is retried with growing gaps (1 minute, 5 minutes, 30 minutes, 2 hours), up to 5 attempts in all. A retry can arrive after you already handled the event, so make your handler safe to run twice.
Verifying the X-Guuds-Signature header
Every delivery is signed so you can tell it came from Guuds and was not changed on the way. When you add an endpoint, Settings shows its signing secret once — store it with your server’s other secrets. The X-Guuds-Signature header is the HMAC-SHA256 of the raw request body, keyed with that secret, written as lowercase hex.
- Read the request body as the exact raw bytes received, before any JSON parsing — parsing and re-serialising changes spacing and key order, and the signature will no longer match.
- Compute HMAC-SHA256 of those bytes with your endpoint’s signing secret and hex-encode it.
- Compare it to the
X-Guuds-Signatureheader with a constant-time comparison (for example Node’scrypto.timingSafeEqual, Python’shmac.compare_digest, PHP’shash_equals), never with==. - If the header is missing or the values differ, reject the request — answer
401and do not act on the body. Only a matching signature means the event is genuine.
const crypto = require('crypto');
const express = require('express');
const app = express();
const SECRET = process.env.GUUDS_WEBHOOK_SECRET; // shown once when the endpoint was added
// Read the body as raw bytes — re-serialising parsed JSON changes them and breaks the signature.
app.post('/webhooks/guuds', express.raw({ type: 'application/json' }), (req, res) => {
const received = String(req.get('X-Guuds-Signature') || '');
const expected = crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');
const a = Buffer.from(received, 'utf8');
const b = Buffer.from(expected, 'utf8');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send('bad signature'); // reject — do not act on the payload
}
const event = JSON.parse(req.body.toString('utf8'));
// ... handle event.event ...
res.sendStatus(200);
});If a secret may have leaked, delete the endpoint in Settings and add it again: the new endpoint gets a new secret, and deliveries signed with the old one stop.