Skip to content

How to receive webhooks

Webhooks tell your server about referral events as they happen, so you don’t have to poll the API. Each time an event you subscribed to happens, Bloop sends an HTTPS POST to a URL you choose.

  1. In Bloop, open Settings, select the Integration tab, then select Manage on the API & Webhooks card.
  2. Under Webhooks, select Add endpoint.
  3. In Endpoint URL, enter the HTTPS URL of your server. Under Events to send, tick the events you want: Referrer joined, Referral pending, Referral succeeded. Every event and its payload is listed in Understanding webhook events.
  4. Select Save. Bloop opens the endpoint page. In the Signing secret card, select Reveal and copy the secret. It starts with whsec_.
  5. Select Send test event to check that your server answers. Bloop shows the HTTP status and the response time, for example “Your endpoint answered HTTP 200 in 123 ms.” Only a 2xx answer counts as delivered. A test event waits up to 8 seconds for your answer.

A store can have up to 5 endpoints, disabled ones included. A new endpoint, or an event you add to an endpoint, starts receiving events within 60 seconds. Events that happen before that are not sent.

Bloop checks the URL when you save it and again before every delivery. The URL is refused when:

  • it does not start with https://;
  • it has a port other than 443, or a username or password;
  • the host is an IP address, a name without a dot, localhost, or a name ending in .localhost, .local or .internal;
  • any address the host name resolves to is not a public internet address (private, loopback, link-local, shared, multicast or reserved ranges, in IPv4 and IPv6);
  • the host name does not resolve to an address;
  • it is longer than 2048 characters.

Bloop does not follow redirects: a 3xx response counts as a failed delivery. The TLS certificate must be valid for the host name.

Each delivery is a POST with a JSON body and these headers:

Header Value
X-Bloop-Topic The event type, for example referrer.joined
X-Bloop-Shop-Domain Your store’s myshopify domain
X-Bloop-API-Version The payload version pinned on the endpoint, v1 today
X-Bloop-Webhook-Id Unique per delivery. It stays the same across retries and changes when you resend
X-Bloop-Event-Id Unique per event, same value as id in the body. Every delivery of the event shares it
X-Bloop-Triggered-At When the event happened, RFC 3339 in UTC
X-Bloop-Hmac-Sha256 The signature of the body, see Verify the signature
Content-Type application/json
User-Agent Bloop-Webhooks/1

The headers follow Shopify’s webhook headers, with X-Bloop- in place of X-Shopify-. Unlike Shopify, the body also names the event, so tools that cannot read headers still know what happened. This is the body of a test event:

{
"id": "evt_0b6f5f8e-7a51-4c3e-9d0a-5f2b8c1e4a77",
"type": "webhook.test",
"api_version": "v1",
"created_at": "2026-10-05T02:00:00Z",
"shop": "test-store.myshopify.com",
"test": true,
"data": {
"message": "This is a test webhook from BLOOP."
}
}

created_at and X-Bloop-Triggered-At hold the same time, in UTC with milliseconds, for example 2026-10-05T02:00:00.000Z. The example above is the shared test vector, which has no milliseconds. Parse these times as RFC 3339 rather than matching one exact format.

Check every request before you trust it. Bloop signs the exact bytes of the body:

  • X-Bloop-Hmac-Sha256 is the Base64 encoding of an HMAC-SHA256 over the raw request body.
  • The key is the endpoint’s signing secret. Use the whole secret, including whsec_.
  • Sign the raw body bytes as received, before any JSON parsing. Parsing and encoding again changes spaces and key order, and the signature no longer matches.
  • Compare signatures in constant time, and answer 401 when they don’t match.

If you already verify Shopify webhooks, the same code works with the Bloop header name and the Bloop secret.

const crypto = require('node:crypto');
function verifyBloopWebhook(rawBody, hmacHeader, secret) {
if (typeof hmacHeader !== 'string' || hmacHeader.length === 0) return false;
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest();
const received = Buffer.from(hmacHeader, 'base64');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

With Express, read the raw body on the webhook route only:

const express = require('express');
const app = express();
app.post('/webhooks/bloop', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyBloopWebhook(req.body, req.get('X-Bloop-Hmac-Sha256'), process.env.BLOOP_WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
res.status(200).send('OK');
queueEvent(req.get('X-Bloop-Webhook-Id'), event);
});

Replace queueEvent with your own queue. In the Flask example, queue_event plays the same role.

<?php
function verifyBloopWebhook(string $rawBody, ?string $hmacHeader, string $secret): bool
{
if ($hmacHeader === null || $hmacHeader === '') {
return false;
}
$expected = base64_encode(hash_hmac('sha256', $rawBody, $secret, true));
return hash_equals($expected, $hmacHeader);
}

Without a framework, read the body from php://input. In Laravel, use $request->getContent() and $request->header('X-Bloop-Hmac-Sha256').

<?php
$rawBody = file_get_contents('php://input');
$hmacHeader = $_SERVER['HTTP_X_BLOOP_HMAC_SHA256'] ?? null;
if (!verifyBloopWebhook($rawBody, $hmacHeader, getenv('BLOOP_WEBHOOK_SECRET'))) {
http_response_code(401);
exit('Invalid signature');
}
$event = json_decode($rawBody, true);
http_response_code(200);
echo 'OK';
import base64
import hashlib
import hmac
def verify_bloop_webhook(raw_body: bytes, hmac_header: str, secret: str) -> bool:
if not hmac_header:
return False
digest = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).digest()
return hmac.compare_digest(base64.b64encode(digest), hmac_header.encode("utf-8"))

With Flask, request.get_data() returns the raw body:

import os
from flask import Flask, request
app = Flask(__name__)
@app.post("/webhooks/bloop")
def bloop_webhook():
raw_body = request.get_data()
signature = request.headers.get("X-Bloop-Hmac-Sha256")
if not verify_bloop_webhook(raw_body, signature, os.environ["BLOOP_WEBHOOK_SECRET"]):
return "Invalid signature", 401
queue_event(request.headers.get("X-Bloop-Webhook-Id"), request.get_json())
return "OK", 200

Run your function on this case before you go live. The secret is a test value that works nowhere. The body is a fixed test input, not a captured delivery.

  • Secret: whsec_TwEw4GXWkMWO2PT2NU1dVNTCJr6SBoUd
  • Body, exactly as below on one line, without a trailing newline:
{"id":"evt_0b6f5f8e-7a51-4c3e-9d0a-5f2b8c1e4a77","type":"webhook.test","api_version":"v1","created_at":"2026-10-05T02:00:00Z","shop":"test-store.myshopify.com","test":true,"data":{"message":"This is a test webhook from BLOOP."}}
  • Expected X-Bloop-Hmac-Sha256: 6Jhy9InIZ2CgSOCiSeQgojwg099YYnj8KyLYJCokdWg=

Your function must accept this case, and reject it when you change one character of the body or remove whsec_ from the secret.

Return a 2xx status within 10 seconds. Bloop reads only the first 4 KB of your response. When the delivery succeeds, the content is ignored. When it fails, the first 256 characters are shown as the error in the app, so don’t put secrets in error responses. Do the work after you answer: save the event, return 200, then process it from a queue.

Anything else counts as a failed delivery: another status, a redirect, a timeout, a connection error or a TLS error.

A failed delivery is retried on this schedule:

Attempt When it is sent
1 Right after the event
2 1 minute after attempt 1 fails
3 5 minutes after attempt 2 fails
4 30 minutes after attempt 3 fails
5 2 hours after attempt 4 fails
6 6 hours after attempt 5 fails
7 12 hours after attempt 6 fails

That is 7 attempts over about 21 hours. If attempt 7 fails, the delivery is marked failed and is not sent again unless you resend it.

The Recent deliveries card on the endpoint page lists the 100 most recent deliveries with their status. A retrying delivery shows “Attempt n of 7” and the time of the next retry. Select Resend on a delivery to send it again: it gets a new X-Bloop-Webhook-Id and the full retry schedule. You can resend only while the endpoint is active and your plan includes the API. Bloop keeps events for 30 days, so you can resend a delivery for 30 days. Test events are sent once, are not retried and cannot be resent.

Bloop delivers every event at least once, in no guaranteed order.

  • Dedupe on X-Bloop-Webhook-Id. A retry repeats the same id, so store the ids you have processed and skip repeats.
  • Group by X-Bloop-Event-Id when you want one action per event, even after a resend. A resend has a new X-Bloop-Webhook-Id but the same event id.
  • Don’t trust arrival order. Compare updated_at to keep the newest copy of a referrer, or read the current state with GET /v1/referrers/{id}.
  • A rejoin is a new event. A referrer who leaves a campaign and joins it again triggers a new referrer.joined, with a new event id and a new joined_at. Don’t drop it as a duplicate.

The signature does not cover a timestamp, so it does not stop a replayed request. HTTPS and deduping on X-Bloop-Webhook-Id cover most cases. If you also reject requests whose X-Bloop-Triggered-At is older than a limit, keep that limit above 21 hours, because retries keep the original time. A resend also keeps the original time, so a limit shorter than 30 days rejects resends of older events.

  • After 24 hours of failing deliveries without a single success, Bloop emails the contact email of your Shopify store. The check runs each time a delivery fails, so the email comes with the first failure after the 24 hours.
  • After 3 days without a success, the next failed delivery disables the endpoint. The endpoint page shows the banner “This endpoint was disabled after repeated failures”, and Bloop emails you again. If events stop for a while and the next one fails, the endpoint can be disabled without the 24-hour email first.
  • One successful delivery resets both clocks. An endpoint with no events to send is never disabled.
  • To turn the endpoint back on, open its page and select Enable, then select Send test event to check it. Events that happened while it was disabled are not sent, and deliveries that were waiting for a retry are marked failed with the reason “Not sent because the endpoint was disabled.”

Select Roll secret in the Signing secret card and confirm. The new secret applies immediately and the old one stops working at once, so update your server right away. Retries already waiting are signed with the new secret. Until you update, deliveries fail verification on your side and are retried on the schedule above.

  • Verifying a re-encoded body. Always sign the raw bytes you received.
  • Removing whsec_ from the secret. The signature then never matches.
  • Doing slow work before answering. Past 10 seconds the delivery fails and is retried, which creates duplicates.
  • Not deduping. A retry can repeat a delivery your server already processed, for example after a timeout.
  • Treating webhooks as complete. Some changes send no event, see Understanding the limitations of v1.