Siren

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:

  1. 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.
  2. 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.
  3. Restart the endpoint with the secret, such as SIREN_WEBHOOK_SECRET=... node endpoint.mjs.
  4. Click “Send test”, the paper-plane button on the webhook’s row. Siren Cloud signs the webhook.test delivery 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

1

Open Developer, then Webhooks, and click "Create Webhook"

2

Enter the "Target URL"

It must be a public https address. Siren Cloud refuses private and local addresses.

3

Pick the events

Choose from the events listed below. The picker highlights any name Siren doesn't send.

4

Add a description if you like, and create it

"Description (optional)" is for you, such as "Production payout notifications".

5

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

EventWhen it’s sentBody fields
sale.createdA sale was recordedopportunityId, source, bindingId
refund.createdA whole order was refunded, cancelled, or voided, or refunds left it worth nothing, which reverses the saletransactionId, the original sale’s transaction
refund.recordedA refund of all or part of an order was written on its transaction as refund lines, once per refundtransactionId, refundId, refundIdFromPlatform, source, amount, currency, whole, remainingValue, refundedAt, lines
renewal.createdA subscription renewal was recordedoriginalTransactionId
lead.createdA lead was recordedopportunityId, source, bindingId
coupon.appliedA collaborator’s coupon was usedcode, opportunityId
transaction.createdA transaction was createdtransactionId
transaction.completedA transaction was completedtransactionId
opportunity.createdAn opportunity was createdopportunityId, trigger
opportunity.invalidatedAn opportunity was invalidatedopportunityId
engagement.createdEngagements were createdengagementCount, strategyId, engagementIds
engagement.awardedAn engagement was awardedengagementId, transactionId, incentiveType, incentiveResolverType, incentiveTypeName, incentiveResolverTypeName
engagement.completedAn engagement was completedopportunityId, conversionType
conversion.createdConversions were createdconversionIds, conversionCount, transactionId, programId, opportunityId, conversionType, incentiveType, incentiveResolverType, incentiveTypeName, incentiveResolverTypeName
conversion.approvedA conversion was approvedconversionId
conversion.rejectedA conversion was rejectedconversionId
conversion.renewedRenewal conversions were createdThe conversion.created fields without opportunityId
obligation.createdAn obligation was createdobligationId, conversionId
obligation.completedAn obligation was put in a payoutobligationId, payoutId, collaboratorId, value
obligation.approvedAn 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 itobligationId, collaboratorId, value, awardType, previousStatus
obligation.rejectedAn 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.supersededThe obligation.approved fields
obligation.supersededAn obligation was rejected and replaced after a refund of part of its sale, one message per obligation. The replacement sends its own obligation.createdobligationId, supersededBy, transactionId, conversionId, reason, changeReference
collaborator.createdAn application was approved, so the applicant became an active collaboratorcollaboratorId
collaborator.registeredSomeone signed upcollaboratorId, source, programIds, distributorIds, groupIds, approveOnSignup
collaborator.appliedA 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 itcollaboratorId, programIds, distributorIds, groupIds
collaborator.declinedAn application was declinedcollaboratorId
collaborator.joinedA collaborator joined programs, distributors, or groupscollaboratorId, programIds, distributorIds, groupIds
collaborator.updatedOne collaborator was edited, from the dashboard or their portal profile. Bulk actions don’t send itcollaboratorId
collaborator.deactivatedA collaborator was deactivated, one message per collaboratorcollaboratorId
collaborator.deletedA collaborator was deleted, or erased at a customer’s requestcollaboratorId
collaborator_group.createdA collaborator group was createdgroupId
collaborator_group.updatedA collaborator group was renamed or its structure changed. An edit that changes both sends it twice, once for eachgroupId, changed, previous, current
collaborator_group.deletedA collaborator group was deletedgroupId
collaborator_group.member_addedA collaborator was added to a group, one message per collaboratorgroupId, collaboratorId
collaborator_group.member_removedA collaborator was removed from a group, one message per collaboratorgroupId, collaboratorId
program.createdA program was created on the dashboard, not by a recipe importprogramId
program.updatedOne program was edited, on the dashboard or through the API. A status change from the program list sends program.status_changed insteadprogramId
program.status_changedA 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 trashprogramId, status
program.deletedA program was deleted for good, from the trash or by a second DELETEprogramId
program_group.createdA program group was createdprogramGroupId
program_group.updatedA program group was editedprogramGroupId
program_group.deletedA program group was deletedprogramGroupId
distributor.createdA distributor was createddistributorId
distributor.updatedOne distributor was edited. Bulk actions don’t send itdistributorId
distributor.deletedA distributor was deleted for gooddistributorId
product.mergedProducts were merged into oneproductId, mergedIds, linkedOwnerIds, unlinkedOwnerIds
coupon.linkedA store’s coupon code was linked to a collaborator, so sales that use it credit themcode, source, sourceLabel, collaboratorId, propertyId, couponId
coupon.unlinkedA 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 collaboratorThe coupon.linked fields
coupon.mergedCoupons were merged into onecouponId, mergedIds, unlinkedHolderIds
store.connectedA 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 againpropertyId, source, sourceLabel, mode
store.disconnectedA store’s connection to a source was turned off and deleted, and Siren Cloud stopped taking that source’s events for itThe store.connected fields
distribution.completedA distribution was completeddistributionId
allocation.completedAn allocation was completeddistributionId, obligationId, status
fulfillment.createdA fulfillment was createdfulfillmentId, status
fulfillment.updatedA fulfillment changed statusfulfillmentId, newStatus
payout.createdA payout was createdpayoutId, collaboratorId, value, currency, status
payout.paidA payout was paid, once per payoutpayoutId, collaboratorId, value, currency
payout.failedAn automatic payout failed, each time it failspayoutId, collaboratorId, value, currency, failureReason
payout.waitingAn 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 endspayoutId, collaboratorId, value, currency, waitingReason
metrics.updatedMetrics were recordedmetricCount, strategyId
credit.issuedStore credit was added to a collaborator’s balancecollaboratorId, currency, amount, sourceType, sourceId, description
credit.redeemedStore credit was taken from a collaborator’s balanceThe credit.issued fields
currency.createdA custom currency was createdcode, name, type, exchangeRate
currency.deletedA custom currency was deletedcode

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:

HeaderValue
Content-Typeapplication/json
X-Siren-EventThe event, such as payout.paid
X-Siren-DeliveryThe delivery’s id, such as evt_ and 32 characters. Retries of one delivery keep the same id
X-Siren-Signaturesha256= and the signature
User-AgentSiren-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.test delivery 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:

  1. 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.
  2. Set your server to accept either secret, and save the new one.
  3. Click “Send test” on the new webhook to check it.
  4. 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 routeDoesScope
GET /webhooksLists webhooksread
GET /webhooks/eventsLists the events you can subscribe toread
GET /webhooks/{id}Reads one webhookread
GET /webhooks/{id}/deliveriesLists its latest 50 deliveriesread
POST /webhooksCreates a webhookmanage
PUT /webhooks/{id}Changes a webhookmanage
DELETE /webhooks/{id}Deletes a webhook and its historymanage
POST /webhooks/{id}/testSends a test deliverymanage

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.

Your privacy choices

Account, checkout and chat you request work without optional tracking. Choose whether to allow these other uses on this website.

You can change or withdraw your choices at any time using “Privacy choices.” We remember your choice for 180 days. Read our privacy policy.