---
url: https://docs.sunlicense.com/guide/automation-webhooks.md
---
# Automation webhooks and notifications

Automations (Lite and Standard) can start when your own system calls them, and tell other systems, your customers or
your Discord server when something happens. This page covers the **Webhook received** trigger and the **Send a
webhook**, **Email the customer** and **Post in Discord** actions.

## Webhook received

Any system that can send an HTTP `POST` with JSON can start an automation: your shop, a payment provider, a CI job or a
script.

1. Create an automation with the **Webhook received** trigger and save it.
2. Create its URL in the trigger's panel (or with `POST /api/v1/automations/{id}/hook`). You get a URL like
   `https://api.sunlicense.com/api/v1/automations/hooks/9nW…`. Treat it like a password: anyone with it can start the
   automation. **Rotate** gives a new one and stops the old one at once.
3. Under **Body fields**, say which parts of the body your conditions and placeholders use, one per line:

   ```text
   order.id string
   order.total double
   customer_email string
   paid bool
   ```

   Types are `string`, `int`, `double`, `bool`, `timestamp` (ISO 8601) and `list` (a list of texts). Or paste an
   example body and SunLicense works the fields out. Each field becomes `body.` plus its path, with `_` and `-`
   turned into camelCase: `customer_email` is `body.customerEmail`. Fields that aren't declared are ignored.
4. Switch the automation on and call it:

```bash
curl -X POST "https://api.sunlicense.com/api/v1/automations/hooks/<token>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1042" \
  -d '{"order": {"id": "1042", "total": 49.0}, "customer_email": "jane@example.com", "paid": true}'
```

The answer is `202 Accepted` with `{"queued": true, "runId": "…"}`. The run then shows up in the automation's history.
Conditions use the fields as declared, for example `body.paid && body.order.total > 20.0`, and placeholders as
{{body.order.id}}. Every run also has `webhook.receivedAt` and `webhook.signed`.

| Answer | Why |
|---|---|
| `202` | Received. `queued: false` when the same `Idempotency-Key` was already received, or the run was skipped (plan, monthly limit); the history says which |
| `400` | The body isn't a JSON object |
| `401` | The webhook requires a signature and it was missing, wrong or older than 5 minutes |
| `404` | Unknown URL (or it was rotated) |
| `409` | The automation is switched off |
| `413` | The body is larger than 64 KB |
| `429` | More than 60 calls a minute to this URL |

### Signed calls

Require a signature (`POST /api/v1/automations/{id}/hook/secret` with `{"on": true}`) to get a secret (`whsec_…`).
Calls must then carry one of:

* `X-SunLicense-Signature: t=<unix seconds>,v1=<hex>`: HMAC-SHA256 with the secret of `<t>.<raw body>`, within 5 minutes
  of now (the same scheme SunLicense uses for the webhooks it sends);
* `X-Hub-Signature-256: sha256=<hex>`: HMAC-SHA256 of the raw body (GitHub's format, so GitHub webhooks work as they are).

```bash
body='{"order":{"id":"1042"}}'
t=$(date +%s)
sig=$(printf '%s.%s' "$t" "$body" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -X POST "$HOOK_URL" -H "Content-Type: application/json" -H "X-SunLicense-Signature: t=$t,v1=$sig" -d "$body"
```

## Send a webhook

Sends JSON to your system's URL with `POST` (or `PUT`, `PATCH`). Leave **Body** empty to send everything the run knows:

```json
{
  "automation": {"id": "…", "name": "Tell the shop"},
  "run": "…",
  "sentAt": "2026-10-25T12:00:00Z",
  "payload": {"license.id": "…", "license.status": "SUSPENDED", "…": "…"}
}
```

Or write your own JSON with placeholders. Put text placeholders inside quotes: {"license": "{{license.id}}", "email": "{{license.customerEmail}}"}. Values are escaped, so a customer's name can never change the shape of your JSON.

Every request is signed with your organization's **signing secret** (`GET /api/v1/automations/signing-secret`, rotate
with `POST …/signing-secret/rotate`; both need automations.write):
`X-SunLicense-Signature: t=<unix seconds>,v1=<hex>`, HMAC-SHA256 of `<t>.<raw body>`. `X-SunLicense-Delivery` is the
run's id: the same on a retry, so you can skip a delivery you already handled.

```js
// Node.js: check a SunLicense webhook
import crypto from "node:crypto";
function verify(rawBody, header, secret) {
  const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
```

* Only addresses on the internet: SunLicense refuses private, local and cloud-metadata addresses, also when a name
  resolves to one or a redirect points at one.
* 5 seconds to answer. A `5xx`, `429` or no answer is tried again after 10 seconds, 1 minute and 5 minutes; any other
  error fails the run with what your server said (the first 200 characters).
* Redirects are followed only with `307` and `308`; for others the run says which URL to use instead.
* Needs the **integrations.write** permission to set up (it sends license data outside SunLicense).

## Email the customer

Emails the license's own customer: the address SunLicense has for the license, never one from the trigger's data. It
uses your [branding](/guide/branding) and the **Automation messages** template under Settings → Customer emails (Lite
and Standard can edit it), shows the key masked, and counts towards your daily email limit and the few emails a day one
customer can get from you. Customers can unsubscribe from automation messages. The email log shows whether each one went
out.

## Post in Discord

* **Channel**: posts in the notification channel of each Discord server you connected, if its *Automation messages*
  notification is on. License keys in the message are masked (`AX8P-XXXX-XXXX-XXXX`). Like license notifications, at
  most 10 a minute per server; the rest become one summary.
* **DM**: messages the Discord account that claimed the license. The license's key, if the message contains it, is
  hidden behind a spoiler. At most 5 an hour per person.

Mentions such as `@everyone` never ping anyone. Both need the **licenses.write** permission to set up, like emails.

## Test runs

A test run shows what each action *would* do ("Would POST signed JSON to example.com") without calling anything. A
re-sent event (for example after a data fix) never starts an automation, so nothing is emailed, posted or sent twice.
