Lucra POSTs an event to your server when something changes, so you don't have to poll.
Set up
Add a webhook URL when you create a key, or have the key set its own with PATCH /v1/keys/current ({ "webhookUrl": "https://…" }; null stops events). Lucra shows a signing secret (whsec_…) once; setting a URL again issues a new one. GET /v1/keys/current reads the URL.
Who hears an event
A key hears events about its own account and, for a partner, the brands it reaches. A partner key also hears every event about a creator active on its roster (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 what was sent to the key's webhook, newest first, with each one's delivery status. It has only events from while the key had a webhook URL.
Events
Events name what changed and the IDs involved; fetch the details from the API. Events about a creator also carry creator. Ignore types you don't handle.
To finish a hosted step a creator or brand has to take, send them to the URL from POST /v1/connections (a platform sign-in, payout setup or a card), then wait for creator.payouts_updated or connection.connected.
| 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 during its hold | 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 |
deposit.succeeded | A deposit settles and its funds are available | deposit |
deposit.failed | The bank debit failed or the Checkout expired unpaid; no funds were added | deposit |
refund.succeeded | A refund from the balance reaches the card or bank | refund |
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 |
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 |
{
"id": "evt_u5s9xZIdD99WBwmDjJPYc3e",
"type": "submission.approved",
"account": "acct_04Jm0JWUg20EhYo2lyNIEHo",
"data": { "submission": "sub_14XA4WGiSOmaVI3B4QLm4Fs" }
}
Verify the signature
Lucra-Signature: t=<seconds>,v1=<hex> is an HMAC-SHA256 of <t>.<raw body> with your secret.
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 2xx within 10 seconds. Failed deliveries retry for 3 days. Events can repeat or arrive out of order, so deduplicate on id.