Qrona Newsletter API · guide
Webhooks
Let your web shop, your CRM or other systems find out immediately when someone signs up, unsubscribes, bounces or reports a mailing as spam – instead of asking us over and over again.
Events
| Event | When |
|---|---|
contact.subscribed | Someone joined a list: signed up in a form, confirmed their sign-up, or was added in the app or via the API. Pending sign-ups (double opt-in) count when they are confirmed. |
contact.unsubscribed | Someone unsubscribed – from a list (list_id) or from everything (list_id is null) – via the link in a mailing, in the app or via the API. |
contact.bounced | The address bounces (does not exist, or does not accept email). It is suppressed across the whole organisation. |
contact.complained | The recipient marked a mailing as spam. The address is suppressed across the whole organisation. |
Changes made through the API are only sent to webhooks with include_api: true, so that an integration does not hear about its own changes. Imports from file send no events.
The messages Qrona sends through the add-on Byråstöd: Klientkommunikation have receipts of their own (message.*), signed the same way, which go only to Qrona: see Messages from Qrona.
Create a webhook
An administrator creates the webhook under Settings → API keys (the Webhooks section), or via the API with a key that has webhooks:write: POST /webhooks. The response contains the secret (whsec_…) that the deliveries are signed with – it is only shown then. A workspace can have 10 webhooks. The URL must be https, on the standard port and reachable from the internet.
curl -X POST https://newsletter.qrona.se/api/v1/webhooks \
-H "Authorization: Bearer $QRONA_NEWSLETTER_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://butik.se/wp-json/qrona/v1/webhook",
"events": ["contact.unsubscribed", "contact.bounced", "contact.complained"],
"description": "Unsubscriptions to WooCommerce"
}'What a delivery looks like
A POST with JSON. id is the event's ID – the same in all webhooks that receive it – and data.list_id is the list it concerned, or null when it concerns the address (unsubscribed from everything, bounce, spam complaint).
POST /wp-json/qrona/v1/webhook HTTP/1.1
Content-Type: application/json
Qrona-Signature: t=1760862600,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Qrona-Event: contact.unsubscribed
Qrona-Delivery: 9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d{
"id": "7f3c1a2e-5b4d-4e6f-8a9b-0c1d2e3f4a5b",
"type": "contact.unsubscribed",
"created_at": "2026-10-19T08:30:00.000Z",
"workspace_id": "0b8f6c2a-1d3e-4f5a-9b7c-8d6e5f4a3b2c",
"data": {
"email": "anna@example.se",
"contact_id": "1042",
"list_id": null,
"source": "one-click",
"via_api": false
}
}Verify the signature
Qrona-Signature is t=<unix-time>,v1=<signature>, where the signature is HMAC-SHA256 in hex of <t>.<body> with the webhook's secret. Compute the same over the raw body, compare in constant time and reject a t that is more than 5 minutes old, and no one can forge or replay a delivery.
<?php
// WordPress: a custom REST route that receives the events. The secret (whsec_…) in wp-config.php.
add_action('rest_api_init', function () {
register_rest_route('qrona/v1', '/webhook', [
'methods' => 'POST',
'permission_callback' => '__return_true',
'callback' => function (WP_REST_Request $request) {
$body = $request->get_body(); // the raw body – not re-encoded JSON
parse_str(str_replace(',', '&', (string) $request->get_header('qrona-signature')), $sig);
$expected = hash_hmac('sha256', ($sig['t'] ?? '') . '.' . $body, QRONA_WEBHOOK_SECRET);
if (empty($sig['v1']) || !hash_equals($expected, $sig['v1']) || abs(time() - (int) $sig['t']) > 300) {
return new WP_REST_Response(['error' => 'signature'], 401);
}
$event = json_decode($body, true);
// The same event can arrive twice: skip an ID you have already seen.
if (get_transient('qrona_event_' . $event['id'])) {
return new WP_REST_Response(null, 204);
}
set_transient('qrona_event_' . $event['id'], 1, WEEK_IN_SECONDS);
if (in_array($event['type'], ['contact.unsubscribed', 'contact.bounced', 'contact.complained'], true)) {
$user = get_user_by('email', $event['data']['email']);
if ($user) update_user_meta($user->ID, 'newsletter_optin', 'no');
}
return new WP_REST_Response(null, 204);
},
]);
});Responding – and retries
- Respond with a 2xx within 10 seconds. Do the heavy work afterwards, in a queue. Redirects are not followed.
- Anything else (error, timeout, 3xx) is retried after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 1 dygn, 1 dygn – 9 attempts over about three days – and is then given up. The same event can therefore arrive more than once: skip an
idyou have already seen. - Events do not always arrive in order. Use
created_atif the order matters. - A webhook whose deliveries have failed 100 times in a row is disabled. Turn it back on in the settings or with PATCH /webhooks/{id} and
{"active": true}. - Test the URL with Send test in the settings or POST /webhooks/{id}/test: a
pingthat is sent immediately. Deliveries from the last 30 days are in GET /webhooks/{id}/deliveries.