Skip to content
All 158 help articles

Send events to your own app (webhooks)

Get a signed message in your own app when a product sells, someone subscribes or a form is submitted. Up to five webhooks, on the Growth plan.

13 min readUpdated
  • webhooks
  • stripe
  • refunds
  • growth
  • orders
  • subscribers
  • forms

Webhooks tell your own app or tools the moment something happens in your community. Drry sends a signed message with the details, so your app can give or remove access, add a subscriber to another tool or log a form answer by itself. It is for coaches who sell access to something built elsewhere, or who run part of their business in other tools.

There are three kinds of event: purchases (a sale, a refund or a dispute), subscribers (a new contact, joining or leaving your mailing list, and list changes) and forms (every form submission, with its answers). You can send up to five webhooks, so different kinds can go to different tools.

Purchases cover shop products (including add-ons bought at checkout) and paid bookings. Memberships, event tickets, payment links and free downloads do not send purchase events today.

  1. Open Settings, then Webhooks

    Webhooks have their own tab in Settings. If you have none yet, the card is called Connect your own app. After that, press Add another webhook. A community can have up to five. Delete one to add another.

    If you set up purchase webhooks earlier, they were on Getting paid. They are on the Webhooks tab now.

  2. Paste your app's webhook address

    It must be a full https address on the standard port, for example https://your-app.example.com/drry-webhook. Drry refuses addresses with a port number, a username or password, plain http, an address over 2,048 characters, or an address that points at Drry itself or a private network.

  3. Pick the events to send

    The events are grouped into Purchases, Subscribers and Forms. A new webhook starts with the five purchase events ticked and the other groups unticked, so you only send people's details when you choose to. Tick what your app needs. You must keep at least one.

  4. 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.

  5. Press Send test event

    Pick which event to send a sample of, then press Send test event. Drry sends that event's sample, with made-up values, and shows right there whether your app answered, with the HTTP status. The body carries "test": true, so your app can tell it from a real one, and you can map the fields in a tool like Zapier before a real event happens. A test that fails is not retried, so fix your app and press the button again. A paused webhook cannot send a test: resume it first.

Events you can get

Purchases

  • 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 of won, lost or no_ruling.

Subscribers

  • contact.created (New contact): somebody new was captured, through any door: a form, a lead magnet and so on.

  • contact.subscribed (Joined your mailing list): somebody joined your mailing list, or came back to it. It only fires when their state actually changes.

  • contact.unsubscribed (Left your mailing list): somebody who was on your mailing list left it. This includes a member turning off their own email switch.

  • contact.added_to_list (Added to a list) and contact.removed_from_list (Removed from a list): one person was put on or taken off one of your lists.

Forms

  • form.submitted (Form submitted): somebody submitted a form, with their answers. Booking intake forms count too.

Bulk actions send nothing. A CSV import, undoing an import, a bulk unsubscribe, adding or removing a whole selection of people on a list, and a step inside an automation do not send subscriber events. Only a change to one person at a time does.

For purchases 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 details sit inside data.

Envelope

  • id: the event id, starting with evt_. It stays the same on every retry and on Send again.

  • type: the event name, such as order.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 purchase event

  • order_id: the order's id.

  • kind: product or booking.

  • line_item: 0 for the main product, 1 for an add-on.

  • product_id: the product's id, or null if you have since deleted the product.

  • product_name: the product's name as it was when sold.

  • buyer_email: the buyer's email, or null if there is none.

  • amount_minor: the price of this line in the smallest currency unit, so 2900 is 29.00. It is the price charged after any offer.

  • currency: the currency code, such as usd.

Purchase events carry no buyer name, address or payment id. Only the email.

order.completed

json
{
  "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.

json
{
  "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.

json
{
  "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).

json
{
  "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.

json
{
  "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"
  }
}

The subscriber events

All five carry the same fields in data: contact_id, email, name (or null), source (the door they came in by) and subscribed (whether they are on your mailing list now). The two list events add list_id and list_name.

Email and name are sent. The message never carries the consent record, an IP address or anything from a member's profile.

json
{
  "id": "evt_Nb8cD2xKp7wZb2sLc9vNgD",
  "type": "contact.subscribed",
  "created": 1761300000,
  "community_id": "5b0e8c1e-3f2a-4c55-9a7d-0d1c6e4f8a21",
  "data": {
    "contact_id": "9d8c7b6a-5f4e-4d3c-8b2a-1f0e9d8c7b6a",
    "email": "subscriber@example.com",
    "name": "Sam Sample",
    "source": "landing_form",
    "subscribed": true
  }
}

form.submitted

data holds the submission, the form and the person, with the answers in the form's own order. Each answer has the question's field_id and label, your own field name as key when the question has one (otherwise null), and the value. Only questions that were answered are listed. contact_id and email are null when the form did not capture a contact.

json
{
  "id": "evt_Pq9rS3xKp6wZb1sLc8vNhE",
  "type": "form.submitted",
  "created": 1761300000,
  "community_id": "5b0e8c1e-3f2a-4c55-9a7d-0d1c6e4f8a21",
  "data": {
    "submission_id": "3c2b1a09-8f7e-4d6c-9b5a-4e3d2c1b0a99",
    "form_id": "6e5d4c3b-2a19-4087-96f5-e4d3c2b1a098",
    "form_name": "Free guide",
    "contact_id": "9d8c7b6a-5f4e-4d3c-8b2a-1f0e9d8c7b6a",
    "email": "subscriber@example.com",
    "answers": [
      { "field_id": "f1", "label": "Your name", "key": "name", "value": "Sam Sample" },
      { "field_id": "f2", "label": "What are you working on?", "key": null, "value": "A spring launch" }
    ]
  }
}

Test events

The Send test event button sends the sample for the event you pick, with the same envelope as a real one and "test": true next to data. A test never counts toward the Failing state and is never sent again. This is a test of order.completed:

json
{
  "id": "evt_Ij7kL1xKp8wZb3sLc0vNfC",
  "type": "order.completed",
  "created": 1761300000,
  "community_id": "5b0e8c1e-3f2a-4c55-9a7d-0d1c6e4f8a21",
  "test": true,
  "data": {
    "order_id": "00000000-0000-4000-8000-000000000001",
    "kind": "product",
    "line_item": 0,
    "product_id": "00000000-0000-4000-8000-000000000002",
    "product_name": "Sample product",
    "buyer_email": "buyer@example.com",
    "amount_minor": 4900,
    "currency": "usd"
  }
}

The headers

Each delivery also carries these headers. Drry-Delivery-Attempt counts up on each retry.

text
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: 1

Check 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-Signature header looks like t=1761300002,v1=<hex>. The v1 value 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 v1 values, new secret first. Accept either.

  • Remember each Drry-Event-Id and 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.

typescript
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, that webhook shows a Failing pill and a banner on the Webhooks tab, and Home shows 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, and Home shows Webhooks paused.

Your list of webhooks and the delivery log

Each saved webhook is one collapsed row showing its host, which kinds of event it sends, its last seven days and its state. It opens by itself when it is failing or paused, when it is your only webhook, and right after you create it, to show its secret. Inside each one, a collapsed delivery log lists each event with its order or contact, 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 Webhooks tab 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 tab, because the messages carry people's emails to an outside address.

Can I send to more than one address?

Yes, up to five webhooks per community, each with its own address, events and secret. Different kinds of event often go to different tools.

I set up purchase webhooks before subscribers and forms existed. Will they start sending people's details?

No. A webhook saved before then keeps sending only the purchase events it had. Subscriber and form events are only sent by a webhook where you tick them.

Do imports and bulk actions send events?

No. A CSV import, undoing an import, a bulk unsubscribe, adding or removing a whole selection on a list, and steps inside an automation send nothing. Only a change to one person at a time does.

Do memberships, event tickets or payment links send webhooks?

Not today. Purchase events cover shop products, add-ons and paid bookings. Free downloads send nothing either, and a new member joining does not send an event.

What does a message contain?

Purchase events have the order, the product and amount details above and the buyer's email, never a name, an address or a Stripe id. Subscriber events have the contact's email and name. Form events have the email and the answers to the form.

What if I delete a contact?

Deleting contacts, or undoing an import, removes their events that were still waiting to be sent.

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 each webhook with its host, which kinds of event it sends and its counts, and whether it is paused or failing. It cannot change anything, and it never sees the secret or the full address.

Related