Skip to content

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

EventWhen
contact.subscribedSomeone 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.unsubscribedSomeone 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.bouncedThe address bounces (does not exist, or does not accept email). It is suppressed across the whole organisation.
contact.complainedThe 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).

Headers
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
Body
{
  "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 id you have already seen.
  • Events do not always arrive in order. Use created_at if 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 ping that is sent immediately. Deliveries from the last 30 days are in GET /webhooks/{id}/deliveries.