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