Outgoing Webhooks
How Vendwick sends signed events to your own servers: endpoints, the event list, payload shapes, signature checks, retries and security limits. Use it to update a CRM, provision accounts, post to chat or feed a data warehouse when an order is paid or refunded, a license is issued, a subscription changes, a dispute opens or a parcel ships.
Turn the Outgoing Webhooks module on under Modules (it needs Licensing), then open Webhooks in the Store settings folder.
- Each endpoint has its own URL, event filter and signing secret.
- Every request carries an HMAC-SHA256 signature, so you can prove it came from your store.
- Failed deliveries are retried for about a day. The delivery log keeps every request and response for 30 days, and you can send any delivery again.
The module is off by default. When it is off, nothing is recorded or sent and every webhook route answers MODULE_DISABLED.
Endpoints
| Field | Notes |
|---|---|
| URL | https:// only. While the store itself runs on a local development host (its site URL is localhost, a loopback address, *.localhost or *.test), http://localhost, 127.0.0.1, [::1] and *.localhost are accepted too. The payment mode plays no part. |
| Description | Shown in the admin only. |
| Events | Any subset of the events below, or all events (including ones added in later versions). |
| Include license keys | Off by default: license payloads carry key: null and a masked key_masked. Turn it on only for a server you control. |
| Status | active, paused (nothing is sent; pending retries wait) or disabled_after_failures (see Retries). |
The signing secret (whsec_...) is shown once, when you add the endpoint or rotate its secret. Copy it then. Vendwick stores it encrypted and never shows it again; the list shows its last four characters. Rotating replaces the secret at once: the next request is signed with the new one only.
A store can have up to 20 endpoints.
Events
| Type | When |
|---|---|
order.paid | A purchase, renewal or upgrade order was paid and written (licenses exist already). |
order.refunded | An order was refunded in full and reversed. |
order.partially_refunded | A refund that did not reach the order total settled. |
order.disputed | The payment provider opened a dispute (chargeback). Its licenses are suspended. |
dispute.closed | A dispute was won (licenses restored) or lost. |
license.issued | A purchase issued a license (one event per license). |
license.renewed | A renewal extended a license. |
subscription.changed | A subscription was created, renewed, upgraded, canceled, resumed, activated, refunded, became past due or expired. |
order.shipped | A shipment left. |
order.delivered | A shipment was marked delivered. |
ping | You pressed Send test event. Sent to that endpoint whatever its filter or status. |
One purchase usually produces several events: order.paid, one license.issued per license and, for a recurring plan, subscription.changed with change: "created". They are independent requests and can arrive in any order.
The request
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Vendwick-Webhooks/0.2.0
Vendwick-Event: order.paid
Vendwick-Delivery: 3:orderPaid:42
Vendwick-Signature: t=1763199000,v1=5f2b...e1
Vendwick-Deliverynames this delivery (one event to one endpoint). It stays the same on every retry and redelivery.Vendwick-Signatureis described under Verifying signatures.- Redirects are never followed: a
3xxanswer is a failed attempt. Point the endpoint at the final URL. - Requests time out after 10 seconds. Answer with a
2xxquickly and do slow work afterwards.
Envelope
{
"id": "evt_3f1c2a9b8d7e6f5a4b3c2d1e",
"type": "order.paid",
"created": 1763199000,
"api_version": "2026-11-15",
"store": { "url": "https://shop.example.com" },
"data": { }
}
ididentifies the event. It is the same on every endpoint and every retry: deduplicate on it, because an event can arrive more than once.createdis the Unix time the event was recorded.api_versionpins the shapes below. A change to them comes with a new version.
Bodies are capped at 64 KiB. When the data would be larger (an order with hundreds of lines), data becomes { "truncated": true, "order": { "id": 42, "number": "CF-..." } }; fetch the details from the admin or MCP tools.
Conventions in every shape:
- Amounts are integers in the currency’s minor unit and end in
_minor(4900is 49.00 USD). - Ids are the numeric ids the admin shows. Times are ISO-8601 UTC strings or
null. - Customer data is limited to email, name and country.
Payload shapes
Order
{
"id": 42,
"number": "CF-20261115-ABCDEF12",
"kind": "purchase",
"status": "paid",
"currency": "USD",
"subtotal_minor": 4900,
"discount_minor": 0,
"tax_minor": 0,
"shipping_minor": 0,
"total_minor": 4900,
"refunded_minor": 0,
"coupon_code": null,
"tax_rate": null,
"tax_country": "GB",
"tax_inclusive": false,
"reverse_charge": false,
"invoice_number": "INV-0001",
"credit_note_number": null,
"payment_provider": "stripe",
"payment_id": "pi_123",
"shipping_status": "not_required",
"dispute_status": null,
"created_at": "2026-11-15T09:30:00.000Z",
"paid_at": "2026-11-15T09:30:00.000Z",
"refunded_at": null,
"customer": { "email": "ada@example.com", "name": "Ada Lovelace", "country": "GB" },
"items": [ ]
}
kind:purchase,renewal,upgradeorclient_invoice.status:paid,partially_refundedorrefunded.shipping_status:not_required,unfulfilled,partial,shipped,deliveredorreturned.dispute_status:open,won,lostornull.itemsis present onorder.paid,order.refunded,order.partially_refundedandorder.shipped. Other events carry the order without it.
Order item
{
"id": 11,
"product_id": 1,
"plan_id": 2,
"product_name": "Speed Kit",
"plan_name": "Personal",
"quantity": 1,
"unit_amount_minor": 4900,
"total_minor": 4900,
"discount_minor": null,
"tax_minor": null,
"refunded_minor": 0,
"parent_item_id": null,
"license_id": 5,
"type": "plugin",
"source": null,
"bump_id": null,
"pricing": "fixed"
}
discount_minor and tax_minor are the item’s share when the order recorded it (orders from 0.2.0 on), else null. Bundle children carry their parent’s id in parent_item_id.
Added in 0.2.0 (same api_version, new fields only):
type: the product type (plugin,theme,digital,serviceand so on). A bundle’s own item isbundle; its children have their own types and point at it withparent_item_id. The bundle item carries the price; children havetotal_minor: 0.source:"bump"for an item bought as an order bump, elsenull.bump_idnames the bump.pricing:"pwyw"when the buyer chose the amount (pay what you want;unit_amount_minoris what they chose), else"fixed".
Orders placed before these fields existed send type from the item and source: null, pricing: "fixed".
License
{
"id": 5,
"key": null,
"key_masked": "VW-ABCDE••••••••••••90123456",
"status": "active",
"product_id": 1,
"plan_id": 2,
"order_id": 42,
"order_item_id": 11,
"customer_email": "ada@example.com",
"activation_limit": 2,
"active_sites": 0,
"expires_at": "2027-11-15T09:30:00.000Z",
"suspended": false,
"created_at": "2026-11-15T09:30:00.000Z"
}
key is the full license key only when the endpoint has Include license keys on. activation_limit 0 means unlimited sites; expires_at null means lifetime. suspended is true while a dispute holds the license.
Subscription
{
"id": 9,
"status": "active",
"license_id": 5,
"product_id": 1,
"plan_id": 2,
"customer_email": "ada@example.com",
"billing_period": "year",
"billing_interval": 1,
"auto_renew": true,
"provider": "stripe",
"current_period_start": "2026-11-15T09:30:00.000Z",
"current_period_end": "2027-11-15T09:30:00.000Z",
"cancelled_at": null,
"created_at": "2026-11-15T09:30:00.000Z"
}
Refund
{
"id": 4,
"status": "succeeded",
"source": "admin",
"amount_minor": 1000,
"currency": "USD",
"reason": "Customer request",
"credit_note_number": "CN-0001",
"lines": [{ "order_item_id": 11, "quantity": 1, "amount_minor": 1000, "revoke": false, "restock": false }],
"created_at": "2026-11-16T10:00:00.000Z"
}
source: admin, provider or dispute. A line’s order_item_id is "shipping" for the shipping charge.
Dispute
{
"provider": "stripe",
"provider_dispute_id": "dp_123",
"status": "open",
"amount_minor": 4900,
"currency": "USD",
"reason": "fraudulent",
"opened_at": "2026-11-20T08:00:00.000Z",
"closed_at": null
}
Shipment
{
"id": 2,
"status": "shipped",
"carrier": "UPS",
"tracking_number": "1Z999",
"tracking_url": "https://www.ups.com/track?tracknum=1Z999",
"items": [{ "order_item_id": 11, "quantity": 1 }],
"shipped_at": "2026-11-16T12:00:00.000Z",
"delivered_at": null
}
data per event
| Type | data |
|---|---|
order.paid | { order, licenses: [License] } (licenses issued or changed by the order) |
order.refunded | { order, licenses: [License] } (after the reversal) |
order.partially_refunded | { order, refund, licenses: [License] } (licenses this refund disabled) |
order.disputed | { order | null, dispute, licenses: [License] } (licenses suspended) |
dispute.closed | { order | null, dispute, outcome: "won" | "lost", licenses: [License] } |
license.issued | { license, order, order_item: Item | null } |
license.renewed | { license, order, previous_expires_at, expires_at } |
subscription.changed | { subscription, change, order | null } with change one of created, renewed, upgraded, cancelled, resumed, refunded, activated, past_due, expired |
order.shipped | { order, shipment } |
order.delivered | { order, shipment } |
ping | { message, endpoint_id } |
Verifying signatures
Vendwick-Signature: t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 of the string <t>.<raw body> keyed with the endpoint secret (the whole whsec_... string, as UTF-8).
- Read the raw request body before any JSON parsing; re-serialized JSON will not match.
- Split the header on commas. Take
tand everyv1. - Reject the request when
tis more than five minutes from your clock (replay protection). - Compute the HMAC and compare it with each
v1in constant time.
Node.js
import crypto from "node:crypto";
function verifyVendwickSignature(secret, header, rawBody, toleranceSeconds = 300) {
const parts = String(header || "").split(",").map((p) => p.trim().split("="));
const t = Number(parts.find(([k]) => k === "t")?.[1]);
const signatures = parts.filter(([k, v]) => k === "v1" && v).map(([, v]) => v);
if (!Number.isInteger(t) || signatures.length === 0) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", secret).update(t + "." + rawBody).digest("hex");
const want = Buffer.from(expected);
return signatures.some((s) => { const got = Buffer.from(s); return got.length === want.length && crypto.timingSafeEqual(got, want); });
}
// Express: read the raw body, verify, then parse.
app.post("/vendwick-webhooks", express.raw({ type: "application/json" }), (req, res) => {
const body = req.body.toString("utf8");
if (!verifyVendwickSignature(process.env.VENDWICK_WEBHOOK_SECRET, req.get("Vendwick-Signature"), body)) {
return res.status(400).send("Bad signature");
}
const event = JSON.parse(body);
// Deduplicate on event.id: the same event can arrive more than once.
res.sendStatus(200);
});
PHP
<?php
function vendwick_verify_signature(string $secret, string $header, string $raw_body, int $tolerance = 300): bool {
$t = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
$pair = explode('=', trim($part), 2);
if (count($pair) !== 2) continue;
if ($pair[0] === 't') $t = ctype_digit($pair[1]) ? (int) $pair[1] : null;
if ($pair[0] === 'v1' && $pair[1] !== '') $signatures[] = $pair[1];
}
if ($t === null || !$signatures) return false;
if (abs(time() - $t) > $tolerance) return false;
$expected = hash_hmac('sha256', $t . '.' . $raw_body, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) return true;
}
return false;
}
$raw_body = file_get_contents('php://input');
$header = $_SERVER['HTTP_VENDWICK_SIGNATURE'] ?? '';
if (!vendwick_verify_signature(getenv('VENDWICK_WEBHOOK_SECRET'), $header, $raw_body)) {
http_response_code(400);
exit('Bad signature');
}
$event = json_decode($raw_body, true);
// Deduplicate on $event['id']: the same event can arrive more than once.
http_response_code(200);
The Webhooks page shows the same code under Verify signatures, with copy buttons.
Retries
Any 2xx answer is a success. Anything else (another status, a redirect, a timeout after 10 seconds, a connection error) is a failed attempt, retried from the store’s 5-minute cron tick:
| After attempt | Next attempt in |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 6 hours |
| 6 | 6 hours |
| 7 | 6 hours |
After the 8th failed attempt (about 21 hours after the first) the delivery is marked failed. Retries run on the cron tick, so each wait can stretch by up to five minutes.
- The first attempt runs right after the event, in the background. If it cannot run then, the next tick sends it.
- Each endpoint gets one request at a time, and a tick sends at most 25 deliveries (5 per endpoint), so one slow endpoint cannot hold up the others.
- After 20 failed attempts in a row, across all its deliveries, the endpoint is set to Turned off after failures and the admin shows the last error. New events are not queued for it; its pending retries wait. Turn it back on from the list once the receiver works again: the failure count resets and the waiting retries go out.
- Any success resets the failure count.
Redeliver in the delivery log sends the stored body again, byte for byte, with a fresh timestamp and signature and the same Vendwick-Delivery id, and starts a new retry schedule. Send test event sends a ping to one endpoint now, even a paused or turned-off one.
Delivery statuses in the log: pending (waiting for an attempt), delivered, failed (every attempt failed) and gave_up, shown as Stopped (the endpoint was deleted, or its URL is no longer allowed). Deliveries older than 30 days are deleted by the daily job.
Security notes
- Verify every request. Anyone can send a POST to your endpoint; only the signature proves it came from your store. Reject stale timestamps to stop replays.
- Keep the secret secret. Store it like a password. Rotate it from the admin if it leaks; the old one stops working at once.
- License keys are masked unless you turn on Include license keys for that endpoint.
- Allowed URLs. Vendwick refuses URLs that are not
https, that contain a user name or password, or whose host is a private, loopback, link-local, carrier-grade NAT, multicast, reserved or cloud metadata address (169.254.169.254,fd00:ec2::254,metadata.google.internal), and localhost-style names (localhost,*.localhost,*.local,*.internal, single-label names). Localhost is allowed only while the store’s own site URL is a local development host, never on a store with a public URL, in test mode or live. The URL is checked again before every send, so a localhost endpoint copied over with a local database is never sent to from a public store. - DNS. The plugin cannot resolve host names itself. EmDash’s outgoing request layer resolves each host and blocks names that point at private addresses, and Vendwick never follows redirects, so a public name cannot bounce a request into your network.
- What the host refuses. EmDash (checked in 1.5.0) refuses, for every plugin request and on every host (production and local alike):
localhostand other internal names, wildcard DNS names, literal private, loopback, link-local and other non-public addresses, and any name whose DNS answer (looked up over DNS over HTTPS) contains one. Redirect targets are checked the same way. So in production a webhook can never reach a private address, even if Vendwick’s own check were bypassed. It also means alocalhostendpoint that Vendwick accepts on a local store is still blocked by EmDash when it sends: to receive webhooks on your machine, expose the receiver through a publichttpstunnel and use that URL. - Capability. Endpoint URLs are your own servers, which no fixed host list can name, so the plugin declares
network:request:unrestricted. Admins confirm it once when upgrading to 0.2.0. - What is stored. The delivery log keeps each body as sent (with full license keys only for opted-in endpoints) and the first 2,000 characters of each response, for 30 days. The audit log records endpoint changes, secret rotations, endpoints turned off and deliveries that failed for good.