Webhooks
Have Siren Cloud post to your server when sales, conversions, payouts, and other events happen, verify each delivery's signature, and manage webhooks from the dashboard or with an API key.
Siren Cloud
Last updated: October 28, 2026
A webhook is how Siren Cloud tells your server that something happened. Rather than your code asking Siren Cloud over and over whether anything changed, Siren Cloud posts a JSON message to an address you choose shortly after each event you picked, such as a sale being recorded or a payout being paid. You’d use one to:
- Add a collaborator to your CRM or mailing list when they sign up or join a program.
- Alert your team when an automatic payout fails.
- Keep your own reports or accounting in step as conversions are approved and payouts are paid.
Webhook events are a separate list from the PHP events in the Events Reference, which code running inside WordPress listens to when it extends the Siren plugin.
Admins and Platform Managers manage webhooks on the Webhooks tab of the Developer screen, on every plan. An organization can have 100 webhooks.
Build and test an endpoint
An endpoint is the page on your server that receives deliveries. It has two jobs: check that each delivery came from Siren Cloud, then answer with any 2xx status. This one uses Node.js with no other packages:
import http from "node:http";
import crypto from "node:crypto";
const secret = process.env.SIREN_WEBHOOK_SECRET;
function isFromSiren(rawBody, signatureHeader) {
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const given = Buffer.from(signatureHeader ?? "");
const wanted = Buffer.from(expected);
return given.length === wanted.length && crypto.timingSafeEqual(given, wanted);
}
http.createServer((req, res) => {
const chunks = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => {
const rawBody = Buffer.concat(chunks);
if (!isFromSiren(rawBody, req.headers["x-siren-signature"])) {
res.writeHead(401).end();
return;
}
const event = req.headers["x-siren-event"];
const body = JSON.parse(rawBody);
console.log(event, req.headers["x-siren-delivery"], body);
res.writeHead(200).end();
});
}).listen(3000);
It checks the signature against the raw body before parsing it, answers 401 to anything that fails, and answers 200 to the rest. Siren Cloud waits 8 seconds at most for the answer, so do slow work after you answer. To try it:
- Run the endpoint where Siren Cloud can reach it at a public https address. Siren Cloud refuses private and local addresses, so on your own machine put it behind a tunnel that gives it a public https address.
- Create a webhook for that address, as Create a webhook below describes, and pick an event such as
payout.paid. Copy the signing secret it shows. - Restart the endpoint with the secret, such as
SIREN_WEBHOOK_SECRET=... node endpoint.mjs. - Click “Send test”, the paper-plane button on the webhook’s row. Siren Cloud signs the
webhook.testdelivery the same way it signs real ones. “Test delivered. The endpoint answered HTTP 200.” means your endpoint checked the signature and accepted it. “Test not delivered: HTTP 401” means the secret your endpoint holds doesn’t match.
Once the test passes, real events reach the same code with their own X-Siren-Event and body. Events lists them.
Create a webhook
Open Developer, then Webhooks, and click "Create Webhook"
Enter the "Target URL"
It must be a public https address. Siren Cloud refuses private and local addresses.
Pick the events
Choose from the events listed below. The picker highlights any name Siren doesn't send.
Add a description if you like, and create it
"Description (optional)" is for you, such as "Production payout notifications".
Copy the signing secret
The "Webhook Created" dialog shows the "Signing Secret" once. Save it now, because you need it to check each delivery and Siren Cloud can't show it again.
The “JSON Manifest” tab creates a webhook from JSON instead, and the download icon on a row, “Export manifest”, shows a webhook’s settings to copy.
A webhook whose stored address breaks the rules shows “Invalid URL, not delivering” and receives nothing until you fix it.
A WordPress site connected through the Siren Cloud Connector has a webhook of its own in this list, with the site’s address. The connector manages it, so don’t edit, pause, or delete it. It counts toward the 100.
Events
| Event | When it’s sent | Body fields |
|---|---|---|
sale.created | A sale was recorded | opportunityId, source, bindingId |
refund.created | A whole order was refunded, cancelled, or voided, or refunds left it worth nothing, which reverses the sale | transactionId, the original sale’s transaction |
refund.recorded | A refund of all or part of an order was written on its transaction as refund lines, once per refund | transactionId, refundId, refundIdFromPlatform, source, amount, currency, whole, remainingValue, refundedAt, lines |
renewal.created | A subscription renewal was recorded | originalTransactionId |
lead.created | A lead was recorded | opportunityId, source, bindingId |
coupon.applied | A collaborator’s coupon was used | code, opportunityId |
transaction.created | A transaction was created | transactionId |
transaction.completed | A transaction was completed | transactionId |
opportunity.created | An opportunity was created | opportunityId, trigger |
opportunity.invalidated | An opportunity was invalidated | opportunityId |
engagement.created | Engagements were created | engagementCount, strategyId, engagementIds |
engagement.awarded | An engagement was awarded | engagementId, transactionId, incentiveType, incentiveResolverType, incentiveTypeName, incentiveResolverTypeName |
engagement.completed | An engagement was completed | opportunityId, conversionType |
conversion.created | Conversions were created | conversionIds, conversionCount, transactionId, programId, opportunityId, conversionType, incentiveType, incentiveResolverType, incentiveTypeName, incentiveResolverTypeName |
conversion.approved | A conversion was approved | conversionId |
conversion.rejected | A conversion was rejected | conversionId |
conversion.renewed | Renewal conversions were created | The conversion.created fields without opportunityId |
obligation.created | An obligation was created | obligationId, conversionId |
obligation.completed | An obligation was put in a payout | obligationId, payoutId, collaboratorId, value |
obligation.approved | An obligation became owed, however it was approved: its conversion was approved, or someone approved it, including one rejected before. One that goes back to approved from complete doesn’t send it | obligationId, collaboratorId, value, awardType, previousStatus |
obligation.rejected | An obligation was rejected and won’t be paid: someone rejected it, its conversion was rejected, or Siren Cloud rejected it after a refund changed its sale, which also sends obligation.superseded | The obligation.approved fields |
obligation.superseded | An obligation was rejected and replaced after a refund of part of its sale, one message per obligation. The replacement sends its own obligation.created | obligationId, supersededBy, transactionId, conversionId, reason, changeReference |
collaborator.created | An application was approved, so the applicant became an active collaborator | collaboratorId |
collaborator.registered | Someone signed up | collaboratorId, source, programIds, distributorIds, groupIds, approveOnSignup |
collaborator.applied | A new applicant confirmed their email address, so their application waits for a decision. collaborator.registered comes earlier, when they send the form. An existing collaborator who applies for more doesn’t send it | collaboratorId, programIds, distributorIds, groupIds |
collaborator.declined | An application was declined | collaboratorId |
collaborator.joined | A collaborator joined programs, distributors, or groups | collaboratorId, programIds, distributorIds, groupIds |
collaborator.updated | One collaborator was edited, from the dashboard or their portal profile. Bulk actions don’t send it | collaboratorId |
collaborator.deactivated | A collaborator was deactivated, one message per collaborator | collaboratorId |
collaborator.deleted | A collaborator was deleted, or erased at a customer’s request | collaboratorId |
collaborator_group.created | A collaborator group was created | groupId |
collaborator_group.updated | A collaborator group was renamed or its structure changed. An edit that changes both sends it twice, once for each | groupId, changed, previous, current |
collaborator_group.deleted | A collaborator group was deleted | groupId |
collaborator_group.member_added | A collaborator was added to a group, one message per collaborator | groupId, collaboratorId |
collaborator_group.member_removed | A collaborator was removed from a group, one message per collaborator | groupId, collaboratorId |
program.created | A program was created on the dashboard, not by a recipe import | programId |
program.updated | One program was edited, on the dashboard or through the API. A status change from the program list sends program.status_changed instead | programId |
program.status_changed | A program was activated, paused, restored from the trash, or moved to the trash, one message per program. The program list’s actions send it, and so does the first DELETE /programs/{id}, which moves the program to the trash | programId, status |
program.deleted | A program was deleted for good, from the trash or by a second DELETE | programId |
program_group.created | A program group was created | programGroupId |
program_group.updated | A program group was edited | programGroupId |
program_group.deleted | A program group was deleted | programGroupId |
distributor.created | A distributor was created | distributorId |
distributor.updated | One distributor was edited. Bulk actions don’t send it | distributorId |
distributor.deleted | A distributor was deleted for good | distributorId |
product.merged | Products were merged into one | productId, mergedIds, linkedOwnerIds, unlinkedOwnerIds |
coupon.linked | A store’s coupon code was linked to a collaborator, so sales that use it credit them | code, source, sourceLabel, collaboratorId, propertyId, couponId |
coupon.unlinked | A store’s coupon code stopped crediting a collaborator, because it was removed or given to someone else. Giving it to someone else also sends coupon.linked for the new collaborator | The coupon.linked fields |
coupon.merged | Coupons were merged into one | couponId, mergedIds, unlinkedHolderIds |
store.connected | A store connected a source such as Stripe or Shopify, and Siren Cloud started taking that source’s events for it. Replacing the signing secret later doesn’t send it again | propertyId, source, sourceLabel, mode |
store.disconnected | A store’s connection to a source was turned off and deleted, and Siren Cloud stopped taking that source’s events for it | The store.connected fields |
distribution.completed | A distribution was completed | distributionId |
allocation.completed | An allocation was completed | distributionId, obligationId, status |
fulfillment.created | A fulfillment was created | fulfillmentId, status |
fulfillment.updated | A fulfillment changed status | fulfillmentId, newStatus |
payout.created | A payout was created | payoutId, collaboratorId, value, currency, status |
payout.paid | A payout was paid, once per payout | payoutId, collaboratorId, value, currency |
payout.failed | An automatic payout failed, each time it fails | payoutId, collaboratorId, value, currency, failureReason |
payout.waiting | An automatic payout can’t go out because its collaborator hasn’t set up Stripe or PayPal, so it waits for them. It’s sent when the first of their payouts starts waiting, and not again for others that start waiting before that wait ends | payoutId, collaboratorId, value, currency, waitingReason |
metrics.updated | Metrics were recorded | metricCount, strategyId |
credit.issued | Store credit was added to a collaborator’s balance | collaboratorId, currency, amount, sourceType, sourceId, description |
credit.redeemed | Store credit was taken from a collaborator’s balance | The credit.issued fields |
currency.created | A custom currency was created | code, name, type, exchangeRate |
currency.deleted | A custom currency was deleted | code |
To get every event, put * in events on the JSON Manifest tab or through the API. “Send test” sends one more type, webhook.test.
Money is a whole number at the currency’s stored scale: value on payouts and obligations, and amount on credit and refund events.
On refund.recorded, refundId is the platform’s id for the refund, or one Siren made up that starts with siren-refund-, and refundIdFromPlatform says which. amount is the money refunded, as a positive number. whole is true when the order is worth nothing after the refund, and remainingValue is what it’s worth. Each of lines has id, refundOf (the sale line it gives back), value (negative when money went back), quantity (units returned), and currency. A refund delivered again that’s already recorded doesn’t send it twice.
On obligation.superseded, supersededBy is the obligation that replaced it, or null when the reward came to nothing. reason is refund, and changeReference is the refund’s id. failureReason on payout.failed is what the payout provider said, with anything shaped like a credential masked. On credit.issued, amount is positive. On credit.redeemed, it’s negative. sourceType says why the balance changed: fulfillment for a store credit payout, redemption for spending credit at checkout, refund when a refunded order gives credit back, and adjustment for an Admin’s change. An Admin’s adjustment sends credit.issued when it raises a balance and credit.redeemed when it lowers one.
On obligation.approved and obligation.rejected, awardType is the currency or custom currency the amount is in, such as USD, and previousStatus is the status the obligation had before, such as draft or rejected. A reward from a test sale sends neither. On payout.waiting, waitingReason is stripe_account_missing or paypal_email_missing. Once the collaborator finishes setting up, each waiting payout goes out and sends payout.paid, as Payouts waiting on a collaborator describes.
On collaborator_group.updated, changed is name or structure, and previous and current are the values before and after the change. Collaborator groups come with the Plus plan and up. On program.status_changed, status is the program’s new status: active, inactive for a paused program or one restored from the trash, or deleted for one in the trash.
On coupon.linked and coupon.unlinked, source is the store platform, such as shopify or stripe, and sourceLabel is its name, such as Shopify. propertyId is the store the code belongs to, or null for a code saved for its platform before the platform belonged to a store. couponId is Siren’s coupon record when the change was made on it, and null otherwise. A store credit code sends neither. On product.merged and coupon.merged, productId or couponId is the record that remains, and mergedIds are the records merged into it. linkedOwnerIds are collaborators who own the remaining product and didn’t before, and unlinkedOwnerIds are collaborators who owned a merged product and don’t own the remaining one. unlinkedHolderIds are collaborators the coupon merge left unlinked. On store.connected and store.disconnected, mode is live or test, and a store’s live and test connections each send their own.
What a delivery looks like
Each delivery is a POST whose body is the event’s fields as a JSON object, with these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
X-Siren-Event | The event, such as payout.paid |
X-Siren-Delivery | The delivery’s id, such as evt_ and 32 characters. Retries of one delivery keep the same id |
X-Siren-Signature | sha256= and the signature |
User-Agent | Siren-Webhooks/1.0 |
A payout.paid delivery’s body looks like this:
{
"payoutId": 412,
"collaboratorId": 37,
"value": 4999,
"currency": "USD"
}
Verify the signature
The signature is an HMAC-SHA256 of the raw request body, keyed with the webhook’s signing secret, written in hex after sha256=. Compute it over the body exactly as it arrived, before parsing it, and compare in constant time.
import crypto from "node:crypto";
function isFromSiren(rawBody, signatureHeader, secret) {
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const given = Buffer.from(signatureHeader ?? "");
const wanted = Buffer.from(expected);
return given.length === wanted.length && crypto.timingSafeEqual(given, wanted);
}
$raw = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $raw, $secret);
if (!hash_equals($expected, $_SERVER['HTTP_X_SIREN_SIGNATURE'] ?? '')) {
http_response_code(401);
exit;
}
The signature doesn’t include a time, so a copied delivery would still verify. Store each X-Siren-Delivery id you handle and ignore one you’ve seen, which also makes retries safe.
Retries
Siren Cloud sends deliveries in the background, never while the event is happening. Any 2xx answer counts as delivered. Siren Cloud doesn’t follow redirects, waits 3 seconds to connect and 8 seconds in all, and treats anything else as a failure.
A failed delivery gets up to 5 attempts in all, about 1, 2, 4, and 8 minutes apart, so the last comes about 15 minutes after the first. Siren Cloud can’t resend a delivery that used up its attempts, so catch up from the ledger, such as GET /payouts. An address that breaks the rules fails at once with no retry. A webhook paused or deleted before Siren Cloud sends a delivery fails that delivery. Siren Cloud never pauses a webhook on its own, however often it fails.
While your organization’s engine is paused, events from stores and API keys wait, so the deliveries they cause go out once processing resumes, as Plans, Trial, and Usage describes. Deliveries already queued, test sends, and dashboard actions such as marking a payout paid still go out.
Test, pause, and delivery history
- Send test. The paper-plane button on an active webhook sends a signed
webhook.testdelivery with the body{ "test": true, "webhookId": 12, "message": "A test delivery from Siren. Real events carry the same headers and signature." }. It’s tried once, and the result shows as “Test delivered. The endpoint answered HTTP 200.” or “Test not delivered:” and the reason. From one IP address, you can send 20 tests to each webhook and 60 in all, each hour. - Pause. Edit the webhook and set Status to “Paused”. A paused webhook receives nothing and can’t be tested, and resuming it sends nothing it missed.
- Delivery History. The history button shows the webhook’s latest 50 deliveries with their event, id, status, HTTP answer, attempts, and date. “Details” shows the body, the answer, any error, and the next retry.
- Delete. Deleting a webhook cancels its pending deliveries and deletes its delivery history.
Change the signing secret
A signing secret can’t be changed or shown again. To replace it, replace the webhook:
- Click the download icon, “Export manifest”, on the old webhook’s row and create a new webhook from that manifest on the “JSON Manifest” tab. The new one counts toward the 100, so delete an unused webhook first if you’re at the limit.
- Set your server to accept either secret, and save the new one.
- Click “Send test” on the new webhook to check it.
- When the old webhook’s “Delivery History” shows nothing waiting for a retry, pause it to keep its history, or delete it.
While both webhooks exist, each event arrives twice with different X-Siren-Delivery ids, so keep the overlap short and skip a repeat by its record id where the body has one, such as payoutId.
Manage webhooks with an API key
An API key with webhook-subscriptions:read or webhook-subscriptions:manage reaches these routes under https://api.sirenaffiliates.com/siren/v1. A key bound to a Property can’t.
| Method and route | Does | Scope |
|---|---|---|
GET /webhooks | Lists webhooks | read |
GET /webhooks/events | Lists the events you can subscribe to | read |
GET /webhooks/{id} | Reads one webhook | read |
GET /webhooks/{id}/deliveries | Lists its latest 50 deliveries | read |
POST /webhooks | Creates a webhook | manage |
PUT /webhooks/{id} | Changes a webhook | manage |
DELETE /webhooks/{id} | Deletes a webhook and its history | manage |
POST /webhooks/{id}/test | Sends a test delivery | manage |
POST /webhooks takes targetUrl, events (up to 100 event names), and an optional description, and answers 201 with the webhook and its signingSecret, which no other answer includes. PUT /webhooks/{id} takes any of targetUrl, events, description, and status (active or paused), and events replaces the whole list. The API doesn’t check event names, so take them from GET /webhooks/events. A webhook whose oauthClientId is set belongs to a connected WordPress site, so leave it alone.