Your server
Webhooks
SolLoop posts to your server when someone subscribes, when a payment is collected or fails, and when a subscription is cancelled or ends, so access can follow payment without polling.
Set up an endpoint
Under Developers in the dashboard, add your endpoint's https URL and choose the events it should receive. You get a signing secret for that endpoint; keep it on your server. Send test event posts a sample event, so you can check your handler before a real payment.
Events
| Event | When | Extra fields in data |
|---|---|---|
| subscription.created | A wallet subscribed to your plan. The first payment follows separately. | subscribedAt |
| subscription.cancelled | The subscriber cancelled. Access should last until endsAt, the end of the period they paid for. | endsAt, immediate |
| subscription.expired | A subscription ended: it was cancelled and ran out, or its term ended. | code, when there is one |
| payment.confirmed | A period was collected, into your wallet. | gross, merchant, fee, signature |
| payment.failed | All attempts this period failed; the period is written off. | amount, code, reason |
| plan.retired | The plan stopped taking new subscribers. Existing ones keep paying. | none; there is no subscriber |
Payload
Every event has an id, a type, a createdAt time, and data that always includes the planId. Every event except plan.retired also has the subscriber's wallet and the subscriptionPda. Amounts are strings in the token's base units: USDC has six decimals, so "49000000" is 49 USDC.
{
"id": "5b0c7f1e-3f5a-4a8e-9a53-2f1d6c0e8b71",
"type": "payment.confirmed",
"createdAt": "2026-10-01T09:14:03.000Z",
"data": {
"planId": "plan_8xK2m",
"subscriber": "H7q2vzHNemDGuPjgsLgVGUeCx5p9UmNZ2xVvKXi6hbzk",
"subscriptionPda": "3Jk9Wq…",
"gross": "49000000",
"merchant": "48510000",
"fee": "490000",
"signature": "3nY6mVtJ…"
}
}Verify the signature
Each request carries a SolLoop-Signature header: t=<unix seconds>,v1=<hex>. The v1 value is an HMAC-SHA256 of the timestamp, a full stop, and the raw request body, keyed with your endpoint's secret. Reject anything that doesn't match, or whose timestamp is more than five minutes from now.
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
export function verifySolLoopSignature(rawBody, header, secret) {
const parts = Object.fromEntries(
(header ?? '').split(',').map((part) => part.trim().split('=')),
);
if (!/^\d+$/.test(parts.t ?? '') || !parts.v1) return false;
// The timestamp is signed, so a captured delivery can't be replayed later.
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && timingSafeEqual(a, b);
}Handle the event
import express from 'express';
import { verifySolLoopSignature } from './verify-solloop.js';
const app = express();
// The raw body: the signature covers the exact bytes we sent, and re-encoded
// JSON will not match them.
app.post('/webhooks/solloop', express.raw({ type: 'application/json' }), async (req, res) => {
const body = req.body.toString('utf8');
const header = req.get('SolLoop-Signature');
if (!verifySolLoopSignature(body, header, process.env.SOLLOOP_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(body);
// Delivery is at least once. Skip an event id you have already handled.
if (await alreadyHandled(event.id)) return res.sendStatus(200);
switch (event.type) {
case 'payment.confirmed':
await extendAccess(event.data.subscriber, event.data.planId);
break;
case 'payment.failed':
await notifyCustomer(event.data.subscriber, event.data.reason);
break;
case 'subscription.expired':
await revokeAccess(event.data.subscriber, event.data.planId);
break;
}
await markHandled(event.id);
res.sendStatus(200);
});Answer with any 2xx status as soon as you've recorded the event, and do slow work afterwards. A response that takes longer than 10 seconds counts as a failure.
Retries and duplicates
A delivery that doesn't get a 2xx is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours. If every retry fails, SolLoop switches the endpoint off and stops queuing events for it. Each attempt is logged under Developers, where Retry sends a delivery again and Turn back on restores a switched-off endpoint, resending the deliveries it gave up on.
Delivery is at least once, so the same event can arrive twice. Keep the ids you've handled and skip repeats.