---
url: https://simpledatalogs.com/docs/integrations/webhooks.md
description: >-
  Turn Stripe, Buy Me a Coffee, Lemon Squeezy, Paddle, Gumroad, Ko-fi, Patreon,
  GitHub, Typeform, Tally or any JSON webhook into events, signed and deduped.
---

# Inbound webhooks

Services you already use can post their webhooks to simpledatalogs. Each delivery becomes one event in your environment, so "someone bought me a coffee" or "a checkout completed" shows up next to your app's own events.

Webhooks are free on every plan. Each delivery that becomes an event counts toward your monthly quota.

## Add a source

1. Open your environment, then **Webhooks → New source**.
2. Pick the service, name it, and pick the deployment its events belong to.
3. Paste the service's signing secret (where to find it is below). You can add it later under **Edit**.
4. Click **Create source** and copy the URL. It's shown once:

```text
https://edge.simpledatalogs.com/v1/hooks/sdl_hook_…
```

5. Paste the URL into the service.

The source shows its last delivery and any refused ones. **New URL** replaces the URL; deliveries to the old one are refused for a week and listed as "Old URL used". **Delete** stops the URL. Events already stored stay either way.

Owners and members manage sources. Viewers see the events but never the URLs or secrets.

## What every event gets

| Field | Value |
|---|---|
| Name | The service's event type, lower case, with `.`, `:` and spaces as `_`: `donation.created` → `donation_created` |
| `event_id` | From the service's own event id, so a retried delivery is stored once |
| `ts` | The service's own time for the event |
| `user_id` | The payer's or respondent's email, stored as an `email:` pseudonym unless your project keeps emails readable |
| `props.webhook` | The service, e.g. `stripe` |
| Other props | Amount (in the currency's own unit, not cents), currency, status, product, plan, country and so on, per service |

Names, addresses, messages, form answers, IP addresses and card details are never stored.

With a secret pasted, a delivery without a valid signature is refused with `401`, and the service retries or shows the failure. Without one, the URL alone authenticates, so keep it private.

## Buy Me a Coffee

**Settings → Webhooks → Add webhook.** Paste the URL, pick the events, and copy its **signing secret** into the source.

| Event | Name | Props |
|---|---|---|
| `donation.created` | `donation_created` | `amount`, `currency`, `status`, `coffee_count`, `support_type` |
| `membership.started` | `membership_started` | `amount`, `currency`, `plan`, `interval`, `status` |
| `extra_purchase.created` | `extra_purchase_created` | `amount`, `currency`, `product` |

Every other type (refunds, commissions, wishlist, recurring donations) is named the same way. Test events have `test: true`.

## Stripe

**Developers → Webhooks → Add destination.** Paste the URL, pick the events, and copy the `whsec_…` **signing secret**.

| Event | Name | Props |
|---|---|---|
| `checkout.session.completed` | `checkout_session_completed` | `amount`, `currency`, `status`, `mode`, `country` |
| `invoice.paid` | `invoice_paid` | `amount`, `currency`, `status`, `plan`, `interval` |
| `customer.subscription.created` | `customer_subscription_created` | `status`, `plan`, `interval` |

Signatures older than 5 minutes are refused, so a captured delivery can't be replayed. Test-mode events have `test: true`.

## Lemon Squeezy

**Settings → Webhooks → +.** Paste the URL, type a **signing secret** of your own, and paste the same secret into the source.

`order_created`, `subscription_created`, `subscription_payment_success` and the rest keep their names; props are `amount`, `currency`, `status`, `product`, `plan` (the variant) and `test`.

## Paddle

**Developer tools → Notifications → New destination.** Paste the URL, pick the events, and copy the destination's **secret key**.

`transaction.completed` becomes `transaction_completed`, `subscription.created` becomes `subscription_created`, and so on; props are `amount` (the grand total), `currency`, `status`, `product` and `interval`. Signatures older than 5 minutes are refused.

## Gumroad

**Settings → Advanced → Ping.** Paste the URL and save. Gumroad doesn't sign pings, so the URL is the secret.

Each sale is a `sale` event (`refund` when the ping says it was refunded) with `amount`, `currency`, `product`, `quantity`, `plan` (the recurrence), `country` and `test`. Card details in the ping are dropped.

## Ko-fi

**More → API → Webhooks.** Paste the URL, then open **Advanced** and copy the **verification token** into the source.

Types become `donation`, `subscription`, `commission` and `shop_order`, with `amount`, `currency`, `plan` (the tier) and the subscription flags.

## Patreon

**Creator portal → My webhooks.** Paste the URL, pick triggers, and copy the webhook's **secret**.

Triggers become names: `members:pledge:create` → `members_pledge_create`. Props are `amount` (the entitled amount), `status`, `charge_status`, `plan` (the tier) and `interval_months`. Patreon sends no event id, so the id comes from the member and its last charge.

## GitHub

**Repository (or organization) → Settings → Webhooks → Add webhook.** Paste the URL, choose content type `application/json`, and type a **secret** there and in the source.

The name is the event plus its action: `issues_opened`, `pull_request_closed`, `star_created`, `push`, `release_published`. Props are `repository`, `action`, `ref`, `number`, `state`, `merged`, `conclusion`, `tag`, `commits` and `stars`. GitHub's `ping` marks the source as delivered but isn't an event.

## Typeform

**Form → Connect → Webhooks.** Add the URL, then **Edit → Secret** and type a secret there and in the source.

Each submission is a `form_response` event with `form_id`, `form` (its title), `answers` (how many) and `score`. Answers themselves are never stored; an email answer becomes the `user_id` pseudonym.

## Tally

**Form → Integrations → Webhooks.** Paste the URL and add a **signing secret**, then paste the same secret into the source.

Each submission is a `form_response` event with `form_id`, `form` and `fields` (how many). Field values are never stored; an email field becomes the `user_id` pseudonym.

## Any other service

Pick **Other (JSON)** for anything that POSTs a JSON object.

| Field | Default | Set it when |
|---|---|---|
| Name | `event`, `type`, `event_type` or `name` | The name lives elsewhere, e.g. `data.kind` |
| ID | `id`, `event_id` or `uuid` | Retries should dedupe on another field |
| Time | `ts`, `timestamp`, `created_at` or `time` | The time lives elsewhere |

Fields are dotted paths into the body (`data.object.id`; a number picks a list item). Other values become props, flattened two levels deep (`service.region`), up to 50; lists and keys that look personal (`email`, `name`, `phone`, `address`, `ip` …) are left out.

Signed services work when they use either:

* **Standard Webhooks** (`webhook-id`, `webhook-timestamp`, `webhook-signature` headers; Svix, Resend, Clerk and others): paste the `whsec_…` secret.
* **An HMAC-SHA256 of the body** in `X-Signature`, `X-Webhook-Signature` or `X-Hub-Signature-256`, as hex or base64, with or without `sha256=`.

A `GET` with `?challenge=` (or `?hub.challenge=`) to the URL echoes the challenge, for services that verify the URL first.

## Answers

| Status | When |
|---|---|
| `200` `{"accepted": "<id>"}` | Stored. Only this means delivered |
| `401` `invalid_signature`, `missing_signature`, `stale_signature` | The secret doesn't match, no signature came, or it's older than 5 minutes |
| `404` `unknown_webhook` | No such URL, or a replaced one |
| `413` | The body is over 512 KB |
| `429` + `Retry-After` | Too many deliveries, or the organization is past 200 % of its quota |
| `503` + `Retry-After` | Busy; the service retries |

Every service above retries non-`200` answers on its own schedule.

## Limits

* 512 KB per delivery; GitHub pushes with thousands of commits can be larger and are refused with `413`.
* 20 sources per environment.
* Each delivery that becomes an event counts toward your quota. Pings and refused deliveries don't.
