# Webhooks

> Events Lucra sends your server, and verifying them.

Source: https://www.onlucra.com/docs/webhooks

Lucra POSTs an event to your server when something changes, so you don't have to poll. Webhooks belong to an API key: each key has at most one webhook URL.

## Set up

Add a webhook URL when you create the key in **Settings → Developers → API**, or have the key set its own:

```ts
const { data } = await lucra.keys.current.update({
  body: { webhookUrl: "https://example.com/lucra/webhooks" },
})
```

The response's `webhookSecret` (`whsec_…`) is the signing secret, shown only once; store it to [verify signatures](#verify-the-signature). Setting a URL again issues a new secret, and `null` stops events. `GET /v1/keys/current` reads the current URL.

## Which events a key gets

A key gets events about its own account. A partner's key also gets events about its client brands, and every event about a creator on its roster (in `data.creator`), wherever it happens: their applications, submissions, messages, retainers, payments, connections and payout setup. An event about a creator alone has `account: null`.

Missed some? `GET /v1/events` lists the events sent to the key's webhook, newest first, each with its `delivery` status. It holds only events from while the key had a webhook URL.

## Events

An event names what changed and the IDs involved; fetch the details from the API. Ignore types you don't handle, since new ones are added.

```json
{
  "id": "evt_0u5s9xZIdD99WBwmDjJPYc3",
  "type": "submission.approved",
  "account": "acct_04Jm0JWUg20EhYo2lyNIEHo",
  "data": { "program": "prog_00lhuAJjQgdtNt5WnqTBc95", "submission": "sub_14XA4WGiSOmaVI3B4QLm4Fs" }
}
```

Some steps happen on a hosted page: a platform sign-in, payout setup or a card. Send the person to the URL from `POST /v1/connections`, then wait for `connection.connected` or `creator.payouts_updated`.

| Event                           | When                                                                                 | `data`                                      |
| ------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------- |
| `application.created`           | A creator applies to a program                                                       | `program`, `application`                    |
| `application.invited`           | A creator is invited and must accept the program's agreements (`agreementsRequired`) | `program`, `application`, `creator`         |
| `application.approved`          | An application is approved                                                           | `program`, `application`                    |
| `application.rejected`          | An application is rejected                                                           | `program`, `application`                    |
| `application.withdrawn`         | A creator withdraws an application                                                   | `program`, `application`                    |
| `agreement.accepted`            | A creator accepts the account's agreements                                           | `creator`, `program` when for one           |
| `submission.created`            | A creator submits work                                                               | `program`, `submission`                     |
| `submission.approved`           | A submission is approved                                                             | `program`, `submission`                     |
| `submission.revision_requested` | A reviewer asks for changes                                                          | `program`, `submission`                     |
| `submission.rejected`           | A submission is rejected                                                             | `program`, `submission`                     |
| `submission.live`               | A submission goes live                                                               | `program`, `submission`                     |
| `file.ready`                    | An uploaded file finishes processing                                                 | `file`                                      |
| `file.failed`                   | An uploaded file can't be used                                                       | `file`                                      |
| `campaign.published`            | A campaign goes live on its platform                                                 | `campaign`                                  |
| `campaign.partially_failed`     | A launch succeeds on some platforms and fails on others                              | `campaign`                                  |
| `campaign.failed`               | A launch fails                                                                       | `campaign`                                  |
| `campaign.paused`               | A campaign is paused                                                                 | `campaign`                                  |
| `campaign.archived`             | A campaign is archived                                                               | `campaign`                                  |
| `campaign.needs_attention`      | A campaign needs action on its ad platform                                           | `campaign`                                  |
| `payment.created`               | A payment to a creator is created                                                    | `payment`, `creator`                        |
| `payment.pending_approval`      | A payment waits for the brand to approve it                                          | `payment`, `creator`                        |
| `payment.succeeded`             | A payment to a creator succeeds                                                      | `payment`, `creator`                        |
| `payment.failed`                | A payment to a creator fails                                                         | `payment`                                   |
| `payment.disputed`              | A payment is disputed before it's sent to the creator                                | `payment`, `creator`                        |
| `payment.refunded`              | A payment is refunded in full                                                        | `payment`                                   |
| `payout.paid`                   | Earnings reach a creator's (or this account's) balance                               | `payout`                                    |
| `payout.failed`                 | Sending earnings to a balance fails                                                  | `payout`                                    |
| `withdrawal.created`            | A withdrawal (the account's or a roster creator's) is on its way                     | `withdrawal`                                |
| `withdrawal.paid`               | A withdrawal (the account's or a roster creator's) reached the bank                  | `withdrawal`                                |
| `withdrawal.failed`             | A withdrawal (the account's or a roster creator's) failed                            | `withdrawal`                                |
| `retainer.accepted`             | A creator accepts a retainer                                                         | `retainer`                                  |
| `retainer.rejected`             | A creator turns a retainer down                                                      | `retainer`                                  |
| `retainer.canceled`             | A retainer is canceled                                                               | `retainer`                                  |
| `retainer.ended`                | A retainer ends                                                                      | `retainer`                                  |
| `sample.created`                | A creator requests a sample                                                          | `sample`                                    |
| `sample.approved`               | A sample request is approved                                                         | `sample`                                    |
| `sample.rejected`               | A sample request is rejected                                                         | `sample`                                    |
| `sample.shipped`                | The store's order for a sample is created                                            | `sample`, `creator`                         |
| `pixel.verified`                | A pixel receives its first event                                                     | `pixel`                                     |
| `conversion.recorded`           | An event counts as one of the account's conversions                                  | `conversion`, `pixel_event`, `submission` when credited to a video |
| `message.created`               | A message arrives in one of the account's threads                                    | `message`, `thread`                         |
| `brand.created`                 | A partner provisions a brand                                                         | `brand`                                     |
| `creator.created`               | A partner adds a creator to its roster                                               | `creator`                                   |
| `creator.payouts_updated`       | A creator's payout setup changes: verified, needs action, or ready                   | `creator`                                   |
| `connection.connected`          | A platform account is connected                                                      | `connection`, `creator` for a creator's     |
| `connection.disconnected`       | A platform account is disconnected                                                   | `connection`, `creator` for a creator's     |
| `connection.expired`            | A platform account needs reconnecting                                                | `connection`, `creator` for a creator's     |
| `ad_authorization.requested`    | A brand asks a creator to authorize a submission as an ad                            | `ad_authorization`, `submission`, `creator` |
| `ad_authorization.granted`      | The creator grants it                                                                | `ad_authorization`, `submission`, `creator` |
| `ad_authorization.revoked`      | It is revoked                                                                        | `ad_authorization`, `submission`, `creator` |

## Verify the signature

Every delivery has a `Lucra-Signature` header: `t=<seconds>,v1=<hex>`, where `v1` is an HMAC-SHA256 of `<t>.<raw body>` keyed with your signing secret. Check it against the raw request body, before parsing it, and reject old timestamps:

```ts
import { createHmac, timingSafeEqual } from "node:crypto"

export function verifyLucraWebhook(
  body: string,
  header: string,
  secret: string
) {
  const { t, v1 } = Object.fromEntries(
    header.split(",").map((part) => part.split("="))
  )
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false
  const expected = createHmac("sha256", secret)
    .update(`${t}.${body}`)
    .digest("hex")
  return (
    v1?.length === expected.length &&
    timingSafeEqual(Buffer.from(v1), Buffer.from(expected))
  )
}
```

## Retries

Answer with a `2xx` within 10 seconds; do slow work after you respond. Failed deliveries retry for 3 days. Events can repeat or arrive out of order, so deduplicate on `id`.
