Send purchases to your own app (purchase webhooks)
Get a signed message in your own app when a product sells, is refunded or is disputed, so it can give or remove access. Available on the Growth plan.
- webhooks
- stripe
- refunds
- growth
- orders
Purchase webhooks tell your own app the moment somebody buys, is refunded or disputes a charge. Drry sends your app a signed message with the order, the product and the buyer's email, so your app can give or remove access by itself. It is for coaches who sell access to something built elsewhere, like an app of your own.
It covers shop products (including add-ons bought at checkout) and paid bookings. Memberships, event tickets, payment links and free downloads do not send webhooks today.
Open Settings, then Getting paid
Find the Purchase webhooks card. Each community has one webhook address.
Paste your app's webhook address
It must be a full
httpsaddress on the standard port, for examplehttps://your-app.example.com/drry-webhook. Drry refuses addresses with a port number, a username or password, plainhttp, an address over 2,048 characters, or an address that points at Drry itself or a private network.Pick the events to send
All five are ticked to start. Untick any your app does not need. You must keep at least one.
Save, then copy your signing secret
Drry shows the secret once, so copy it into your app straight away. It starts with
whsec_. If you lose it, regenerate it.Press Send test event
Drry sends a signed
webhook.testevent and shows right there whether your app answered, with the HTTP status. A test that fails is not retried, so fix your app and press the button again.
Events you can get
order.completed(Purchase completed): a product or booking was paid for.order.refunded(Refunded in full): that order line was refunded in full.order.partially_refunded(Partly refunded): money went back that does not match one whole line, such as a goodwill part refund.order.disputed(Charge disputed): the buyer disputed the charge with their bank.order.dispute_closed(Dispute closed): the dispute ended, with an outcome ofwon,lostorno_ruling.
You get one event per order line. A purchase with an add-on is two lines, so it sends two order.completed events, and a full refund of it sends two order.refunded events. Refunding only the add-on sends one, for the add-on.
Refund and dispute events only go out for orders that were completed. A refund or dispute that reached Drry before the purchase did sends nothing at that point.
The message your app receives
Every delivery is a POST with a JSON body. The body has the same envelope for every event, and the order details sit inside data.
Envelope
id: the event id, starting withevt_. It stays the same on every retry and on Send again.type: the event name, such asorder.completed.created: when the event happened, in Unix seconds.community_id: your community's id.data: the order details below.
Fields in data on every order event
order_id: the order's id.kind:productorbooking.line_item:0for the main product,1for an add-on.product_id: the product's id, ornullif you have since deleted the product.product_name: the product's name as it was when sold.buyer_email: the buyer's email, ornullif there is none.amount_minor: the price of this line in the smallest currency unit, so2900is 29.00. It is the price charged after any offer.currency: the currency code, such asusd.
There is no buyer name, address or payment id in the message. Only the email.
order.completed
{
"id": "evt_9mQ2rT7xKp4wZb1sLc8vNa",
"type": "order.completed",
"created": 1761300000,
"community_id": "5b0e8c1e-3f2a-4c55-9a7d-0d1c6e4f8a21",
"data": {
"order_id": "c1d2e3f4-0a1b-4c2d-8e3f-9a8b7c6d5e4f",
"kind": "product",
"line_item": 0,
"product_id": "7a6b5c4d-3e2f-4a1b-9c8d-1e2f3a4b5c6d",
"product_name": "The Sourdough Starter Kit",
"buyer_email": "buyer@example.com",
"amount_minor": 2900,
"currency": "usd"
}
}order.refunded
Adds refunded_total_minor: the total the whole charge has had refunded so far. It spans every line of the purchase, so on an add-on purchase it can be larger than this line's amount_minor. In this example only the add-on was refunded.
{
"id": "evt_Qw3rT9xKp2wZb7sLc4vNbY",
"type": "order.refunded",
"created": 1761300000,
"community_id": "5b0e8c1e-3f2a-4c55-9a7d-0d1c6e4f8a21",
"data": {
"order_id": "c1d2e3f4-0a1b-4c2d-8e3f-9a8b7c6d5e4f",
"kind": "product",
"line_item": 1,
"product_id": "2f1e0d9c-8b7a-4695-8473-625140f9e8d7",
"product_name": "Recipe Binder Add-on",
"buyer_email": "buyer@example.com",
"amount_minor": 1200,
"currency": "usd",
"refunded_total_minor": 1200
}
}order.partially_refunded
Also carries refunded_total_minor. The order details are for the main product (line_item 0), because the money did not match one line. A second part refund sends a new event with the new total.
{
"id": "evt_Lm4nP8xKp1wZb6sLc3vNcZ",
"type": "order.partially_refunded",
"created": 1761300000,
"community_id": "5b0e8c1e-3f2a-4c55-9a7d-0d1c6e4f8a21",
"data": {
"order_id": "c1d2e3f4-0a1b-4c2d-8e3f-9a8b7c6d5e4f",
"kind": "product",
"line_item": 0,
"product_id": "7a6b5c4d-3e2f-4a1b-9c8d-1e2f3a4b5c6d",
"product_name": "The Sourdough Starter Kit",
"buyer_email": "buyer@example.com",
"amount_minor": 2900,
"currency": "usd",
"refunded_total_minor": 500
}
}order.disputed
Adds dispute_amount_minor, the amount the bank is disputing for the whole charge, and respond_by, the evidence deadline in Unix seconds (or null).
{
"id": "evt_Ab5cD9xKp0wZb5sLc2vNdA",
"type": "order.disputed",
"created": 1761300000,
"community_id": "5b0e8c1e-3f2a-4c55-9a7d-0d1c6e4f8a21",
"data": {
"order_id": "c1d2e3f4-0a1b-4c2d-8e3f-9a8b7c6d5e4f",
"kind": "product",
"line_item": 0,
"product_id": "7a6b5c4d-3e2f-4a1b-9c8d-1e2f3a4b5c6d",
"product_name": "The Sourdough Starter Kit",
"buyer_email": "buyer@example.com",
"amount_minor": 2900,
"currency": "usd",
"dispute_amount_minor": 2900,
"respond_by": 1762000000
}
}order.dispute_closed
Adds dispute_amount_minor and outcome, which is won, lost or no_ruling. Treat won and no_ruling as access given back, and lost as access taken away.
{
"id": "evt_Ef6gH0xKp9wZb4sLc1vNeB",
"type": "order.dispute_closed",
"created": 1761300000,
"community_id": "5b0e8c1e-3f2a-4c55-9a7d-0d1c6e4f8a21",
"data": {
"order_id": "c1d2e3f4-0a1b-4c2d-8e3f-9a8b7c6d5e4f",
"kind": "product",
"line_item": 0,
"product_id": "7a6b5c4d-3e2f-4a1b-9c8d-1e2f3a4b5c6d",
"product_name": "The Sourdough Starter Kit",
"buyer_email": "buyer@example.com",
"amount_minor": 2900,
"currency": "usd",
"dispute_amount_minor": 2900,
"outcome": "won"
}
}webhook.test
Only sent by the Send test event button. It has no order details.
{
"id": "evt_Ij7kL1xKp8wZb3sLc0vNfC",
"type": "webhook.test",
"created": 1761300000,
"community_id": "5b0e8c1e-3f2a-4c55-9a7d-0d1c6e4f8a21",
"data": {
"message": "A test event from Drry. Your endpoint is receiving signed webhooks."
}
}The headers
Each delivery also carries these headers. Drry-Delivery-Attempt counts up on each retry.
Content-Type: application/json
User-Agent: Drry-Webhooks/1.0 (+https://drry.com)
Drry-Signature: t=1761300002,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e459e1c2
Drry-Event-Id: evt_9mQ2rT7xKp4wZb1sLc8vNa
Drry-Event-Type: order.completed
Drry-Delivery-Attempt: 1Check that a message really came from Drry
Anyone can post to a public address, so check the signature before you trust a message.
The
Drry-Signatureheader looks liket=1761300002,v1=<hex>. Thev1value is an HMAC-SHA256, written in hex, of the timestamp, a dot, then the raw body:t + "." + rawBody.The key is the whole secret string,
whsec_included. Do not strip the prefix and do not base64-decode it.Check against the raw body, before you parse the JSON.
Refuse a timestamp more than 5 minutes from your clock.
For 24 hours after you regenerate the secret, the header carries two
v1values, new secret first. Accept either.Remember each
Drry-Event-Idand ignore one you have already handled. Delivery is at least once, so the same event can arrive twice.
This example works in Deno, Supabase Edge Functions, Cloudflare Workers and Node 18 or later. It is the same code the Purchase webhooks card offers under How your app checks a delivery.
const TOLERANCE_S = 300;
function hexToBytes(hex: string): Uint8Array | null {
if (!/^[0-9a-f]{64}$/i.test(hex)) return null;
const out = new Uint8Array(32);
for (let i = 0; i < 32; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
return out;
}
export async function verifyDrry(req: Request, secret: string): Promise<unknown | null> {
const raw = await req.text(); // the RAW body; check before JSON.parse
let tRaw = "";
const sigs: Uint8Array[] = [];
for (const part of (req.headers.get("Drry-Signature") ?? "").split(",")) {
const i = part.indexOf("=");
if (i < 0) continue;
const k = part.slice(0, i).trim();
const v = part.slice(i + 1).trim();
if (k === "t") tRaw = v;
else if (k === "v1") { const b = hexToBytes(v); if (b) sigs.push(b); }
}
const t = Number(tRaw);
if (!/^\d+$/.test(tRaw) || Math.abs(Date.now() / 1000 - t) > TOLERANCE_S) return null;
if (sigs.length === 0) return null;
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret), // the whole "whsec_..." string; never base64-decode it
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"],
);
const signed = new TextEncoder().encode(`${tRaw}.${raw}`);
// During a regeneration the header holds two v1 values; either may match.
for (const sig of sigs) {
if (await crypto.subtle.verify("HMAC", key, sig, signed)) return JSON.parse(raw);
}
return null;
}
// Supabase Edge Function. Deploy with: supabase functions deploy drry-webhook --no-verify-jwt
Deno.serve(async (req) => {
const event = await verifyDrry(req, Deno.env.get("DRRY_WEBHOOK_SECRET") ?? "");
if (!event) return new Response("bad signature", { status: 400 });
// Dedupe on req.headers.get("Drry-Event-Id") before granting anything, then answer 2xx fast.
return new Response("ok");
});What counts as delivered, and what happens when it is not
Any 2xx answer within 10 seconds is delivered. Answer fast and do the slow work afterwards.
Anything else is retried: no answer, a timeout, a 5xx, a 429 or any other 4xx. Drry does not follow redirects, so a 3xx counts as a failure. Use the final address.
There are 7 attempts over about 35 hours: right away, then about 1 minute, 6 minutes, 36 minutes, 2.5 hours, 10.5 hours and 34.5 hours after the first try. After the last one the delivery is marked Failed.
Your app answering 410 Gone pauses the webhook at once.
If your app has not accepted a delivery in 3 days while events keep failing, Drry pauses the webhook.
While a webhook is paused, new events wait instead of being tried, and they go out when you press Resume. Events not delivered within 30 days are deleted.
When your app is failing
Once an event fails every retry, the card shows a Failing pill and a banner, and Home shows Purchase webhooks are failing under Needs your eye. Drry also emails the owner once per incident, and at most once a day per webhook. A paused webhook shows Paused with the reason.
The delivery log
Under the card, a collapsed delivery log lists each event with its order, status (Queued, Sending, Retrying, Sent or Failed), HTTP status, attempt number and time. Filter it to Failed, and use Show more for older ones. Send again re-sends a Sent or Failed event with the same event id, for up to 30 days.
Secret, pause and delete
Regenerate secret gives you a new secret. The old one keeps signing alongside it for 24 hours, so update your app before then.
Pause and Resume hold and release events. Delete removes the address, any waiting events and the log.
If Drry can no longer read the saved secret, it pauses the webhook. Regenerating the secret fixes it and resumes it.
Common questions
Is this available below Growth?
No. Below Growth the card shows locked with an upgrade prompt. If you drop below Growth with a webhook saved, nothing sends, and you can still pause or delete it. Events that came up while on the lower plan are not sent.
Who can set this up?
Owners and admins. Editors do not see the card, because the message carries buyer emails to an outside address.
Can I send to more than one address?
Not yet. Each community has one webhook address.
Do memberships, event tickets or payment links send webhooks?
Not today. Webhooks cover shop products, add-ons and paid bookings. Free downloads send nothing either.
Does it send anything I could not read from the order?
No. The message only has the order, product and amount details above and the buyer's email. It never has a name, an address or a Stripe id.
Do I need to add a webhook in Stripe?
No. Drry sends these itself, so there is nothing to set up in Stripe.
A delivery shows Failed. Can I get it back?
Yes. Fix your app, then press Send again on it in the delivery log. It goes out with the same event id.
Can my assistant see this?
Yes, read only. An assistant connected to your community can show whether a webhook is set up, its host, the events and counts, and whether it is paused or failing. It cannot change it, and it never sees the secret or the full address.