Some products cannot be a file in a download: an account on your own service, a seat in a course platform you run, a licence your own software checks. For those, Getly tells your server the moment a sale completes, and your server does the delivery. That message is the sale.completed webhook.
It matters more since 26 September 2026. A paid listing whose only content promises delivery later by email is now sent to review instead of going live, unless the delivery is automated in a way Getly can verify: Getly licence keys, your own key pool, or a working sale.completed webhook. This guide covers the third route, end to end. Measured on 26 September 2026, Getly licence keys are used on 0.11% of active listings and own key pools on 0.10%; if a key is all you deliver, those two are simpler than a server. Use the webhook when delivery is something only your system can do.
Step 1: Register the endpoint
- In the dashboard open Developer, then Webhooks, and press Add Endpoint.
- Endpoint URL: a public https address on your server. Plain http, localhost and private network addresses are refused.
- Store: the shop whose sales this endpoint should receive.
- Events: tick Sale Completed itself. There is also All Events, but the check that lets email-promised delivery go live looks for the sale.completed subscription by name, so tick the event explicitly.
- Press Register Endpoint.
A box then shows Save your signing secret now. This is the only time the secret is displayed. Copy it into your server's environment (the examples below call it GETLY_WEBHOOK_SECRET). Editing the endpoint later keeps the same secret; deleting it and creating a new one issues a new secret, and your server would reject deliveries until you update it.
Step 2: Know what arrives
Every delivery is a POST with a JSON body and three headers: X-Getly-Event (the event name), X-Getly-Signature-V2 and the older X-Getly-Signature. A sale.completed body looks like this:
{
"deliveryId": "uuid",
"event": "sale.completed",
"data": {
"orderId": "uuid",
"buyerId": "uuid",
"buyerEmail": "[email protected]",
"items": [
{
"orderItemId": "uuid",
"productId": "uuid",
"price": 2999,
"sellerAmount": 2399,
"isGift": false,
"licenseKey": null
}
],
"total": 2999
},
"timestamp": "2026-10-11T09:00:00.000Z"
}
Points worth knowing before you write a line of code:
- Amounts are in cents: 2999 is $29.99.
- One delivery covers your items in one order. A cart with products from several shops sends each shop only its own lines, and
totalis the sum of those lines. itemscan hold more than one product. Loop over it; do not read only the first entry.buyerEmailis the address the buyer paid with, the same one you already see under Customers. WhenisGiftis true, it is still the giver's address; the recipient's address is not shared, so send the gifted item to the buyer.licenseKeyholds the key issued for that item when the listing uses Getly keys or your key pool, andnullotherwise or while the key is still waiting for you to add keys.- Sales made through a checkout link also carry
checkoutLinkId,referenceandmetadata, so you can match the payment to what created the link.
Step 3: Verify the signature
Anyone can send a POST to your URL. The signature is how you know Getly sent this one. The header looks like t=1760173200,v1=5f2c...: t is a Unix timestamp in seconds and v1 is the hex HMAC-SHA256 of the string t, a full stop, and the raw request body, keyed with your signing secret.
Two details break most first attempts. First, sign the raw body exactly as received. Parsing the JSON and serialising it again can change spacing or key order, and the signature no longer matches. Second, reject old timestamps: five minutes of tolerance is the recommended window, and it stops someone from replaying a captured delivery later.
Here is a complete handler for Node.js with Express:
const crypto = require('crypto');
const express = require('express');
const app = express();
const SECRET = process.env.GETLY_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;
function verifyGetly(rawBody, header) {
if (!header) return false;
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!Number.isFinite(t) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = crypto.createHmac('sha256', SECRET).update(`${t}.${rawBody}`).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(parts.v1, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// express.raw keeps the body as bytes, exactly as Getly signed it.
app.post('/getly/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const raw = req.body.toString('utf8');
if (!verifyGetly(raw, req.get('X-Getly-Signature-V2'))) {
return res.status(401).send('invalid signature');
}
const { event, data } = JSON.parse(raw);
if (event !== 'sale.completed') return res.sendStatus(200); // includes the dashboard "test" event
for (const item of data.items) {
if (await alreadyDelivered(item.orderItemId)) continue; // your database
await deliver({ // your system
email: data.buyerEmail,
productId: item.productId,
licenseKey: item.licenseKey,
});
await markDelivered(item.orderItemId);
}
res.sendStatus(200);
});
app.listen(3000);
alreadyDelivered, deliver and markDelivered are yours to write: a table keyed by orderItemId and whatever creates the account or sends the email on your side.
Step 4: Answer fast, and deliver exactly once
Getly waits 10 seconds for a 2xx response. Anything else counts as a failure: a 4xx or 5xx, a timeout, and also a redirect, because deliveries do not follow redirects. Point the endpoint at the final URL, not one that bounces to another.
A failed delivery is retried with growing gaps: after about 1 minute, then 5 minutes, 30 minutes and 2 hours. After the fifth attempt it is marked as failed. Retries are why the handler above keys on orderItemId. If your server delivered but answered too slowly, the same order arrives again, and without the check the buyer gets two accounts or two emails. orderItemId is stable across attempts; the timestamp and signature are not. If delivery itself takes longer than a few seconds, record the order, answer 200, and do the slow work in a background job.
Step 5: Test before you rely on it
On the endpoint's card, press Test. Getly sends a signed event named test with a short message in data, and the toast tells you whether your server returned 2xx; when it did not, the toast quotes the start of your server's response or the connection error, which is the fastest way to see why. Logs lists recent deliveries with the event, the status code your server returned and the number of attempts.
Test purchases from the seller sandbox do not send webhooks; they are kept out of everything that touches other systems. Test the signature and the response with the Test button, and test your delivery code with a real low-priced listing.
Keep the endpoint healthy. The waiver for email-promised listings is lost on a proven bad record: three or more deliveries in 30 days with fewer than 90% answered with 2xx. Failed test deliveries on the same endpoint count too, so fix the endpoint before you press Test twenty times.
What to do today
Register an endpoint with Sale Completed ticked, store the secret, deploy the handler above with your own deliver function, and press Test until the toast says the delivery succeeded. Then open your listing's description and state plainly what the buyer receives and when, for example "an account is created for the email you pay with, within a minute of payment". The webhook does the work; the description is what the buyer reads before paying.


