Webhooks
Get a signed POST the moment a design is ordered or an order is cancelled, with retries and a delivery log.
Webhooks send signed JSON to your URL as soon as something happens, so a production system can start work right away instead of polling the REST API. They are part of the Pro and Plus plans.
Add an endpoint
In the app, open Settings, Webhooks and add an https:// URL on a public host. Pick the events you want. The signing secret (whsec_...) is shown once when you create the endpoint; store it with your app's other secrets. Up to 5 endpoints per store.
Use Send test event to check your endpoint. It sends your latest ordered design as a design.ordered sample (or a ping if there is none yet), marked with "test": true.
Events
| Event | When | data |
|---|---|---|
design.ordered |
Once per design, when the Shopify order links it | { "design": Design } |
order.cancelled |
An order with custom items is cancelled | { "order": Order } |
design.created |
A buyer saves a design on the storefront. Off by default; high volume | { "design": Design } |
ping |
Test event when there is no ordered design yet | { "message": "..." } |
Design and Order are the same objects the REST API returns, including image URLs. Money is in cents.
The request
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: ModdifyStudio-Webhooks/1.0
X-Moddify-Event: design.ordered
X-Moddify-Delivery: 01JB2Y3ZK4D5E6F7G8H9J0KMNP
X-Moddify-Timestamp: 1792405231
X-Moddify-Signature: 5f0c1d...e9
{
"id": "evt_01JB2Y3ZK2V9W8X7Y6Z5A4B3C2",
"event": "design.ordered",
"apiVersion": "2026-10",
"createdAt": "2026-10-19T10:20:31+00:00",
"shop": "your-store.myshopify.com",
"data": { "design": { "id": "01JB2X7M0N8Q4R5S6T7V8W9XYZ", "status": "ordered" } }
}
- The event
idstays the same when a delivery is retried or resent. Use it, or the design id, to ignore duplicates. - Answer with any 2xx within 5 seconds and do slow work after responding.
- Redirects are not followed and count as failures.
Verify the signature
X-Moddify-Signature is the hex HMAC-SHA256 of {timestamp}.{raw body} with your endpoint's secret. Check it against the raw request body before parsing the JSON, compare in constant time, and reject timestamps older than about 5 minutes.
PHP:
$body = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_MODDIFY_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_MODDIFY_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $timestamp.'.'.$body, getenv('MODDIFY_WEBHOOK_SECRET'));
if (! hash_equals($expected, $signature) || abs(time() - (int) $timestamp) > 300) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
Node (Express):
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.post('/moddify/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.get('X-Moddify-Timestamp') ?? '';
const signature = req.get('X-Moddify-Signature') ?? '';
const expected = crypto
.createHmac('sha256', process.env.MODDIFY_WEBHOOK_SECRET)
.update(`${timestamp}.${req.body}`)
.digest('hex');
const ok = signature.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
&& Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body);
res.sendStatus(200);
// handle event.event and event.data here
});
Retries
A non-2xx answer, a timeout (5 seconds, 3 to connect), a connection error or a redirect is a failure. Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours, then marked failed. Test events are not retried.
After 20 failed attempts in a row the endpoint is switched off and the app shows a banner. Turn it back on from the endpoint page once it works again.
Delivery log
Each endpoint page lists its last 50 deliveries with the status code, duration and the start of your response. Redeliver sends the same body again, with the same event id.
Security
Only public https:// hosts are accepted. The host is checked when you add the endpoint and again before every delivery; private and reserved network addresses are refused.