Qrona Newsletter API · guide
E-commerce
Connect a web shop: send in customers, products, orders and carts, and you can target mailings based on what people have bought and see how much each campaign sold.
The model
- Everything belongs to a store that you name yourself, for example
butik-se. A workspace can have up to 20 stores. Create it with PUT /stores/{store_id}. - Under the store there are customers, products with variants, orders with lines (at most 500) and carts. They all use the store's own IDs – WooCommerce order 50087 becomes
/stores/butik-se/orders/50087. - Everything is written with
PUT, which creates or updates. The same call twice gives the same result, so an import can be rerun and an order is sent again each time it changes. Fields you leave out are kept; lists (variants, lines) are replaced when sent. - An order or cart is sent with its customer, who is created or updated at the same time. The products do not need to have been sent in for an order to refer to them.
- The scopes are
ecommerce:readandecommerce:write. The person behind the key must not have contact rules: customers are not contacts and cannot be restricted in the same way.
curl -X PUT https://newsletter.qrona.se/api/v1/stores/butik-se \
-H "Authorization: Bearer $QRONA_NEWSLETTER_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Exempelbutiken", "domain": "butik.se", "currency": "SEK" }'Customers and consent
A customer is someone who has made a purchase – with or without consent to newsletters. That is why customers are kept entirely separate from contacts:
- A customer never becomes a contact and never receives a mailing because they have made a purchase.
- If the customer has ticked the newsletter box: sign them up to a list with POST /lists/{id}/members, with the text they agreed to – just as without e-commerce.
- A customer is linked to the contact with the same email address in the workspace as long as such a contact exists (
contact_idon the customer). Only then do the purchases show up in the distribution lists' conditions. - When a customer wants to be erased from the shop: DELETE /stores/{store_id}/customers/{customer_id}. If a contact is erased in Qrona Newsletter (GDPR), customers with the same address are removed in the same way. The orders remain without a customer, so that the campaigns' revenue stays correct.
Revenue per campaign
Set the store's domain. Links to the shop (and its subdomains) in the workspace's mailings then get the parameter qn_cid=<campaign id>, which click tracking then carries along. Save the value when the customer arrives at the shop – in a cookie or in the session – and send it as campaign_id on the order.
- Only paid orders (
financial_status: "paid") count. Refunded or cancelled: send the order again with that status. - A
campaign_idthat is not one of the workspace's campaigns is not saved – but the order is. - The revenue appears on the campaign's page in the app and in GET /revenue, per currency.
Purchases in distribution lists
When a workspace has a store, the distribution lists' conditions get one more group, Purchases in the web shop:
- Purchase – has bought something, or has never bought anything.
- Purchased product – has bought (or not bought) a specific product, or any of several.
- Total purchase amount – the sum of all paid orders is more or less than an amount. “Less than” also counts anyone who has never made a purchase.
- Last purchase – within the last N days, or more than N days ago (someone who has bought but not for a while).
The conditions apply to contacts, and only paid orders count. Combine them with the other conditions, for example a list or a tag.
Example: WooCommerce
Three hooks are enough: store qn_cid when the customer arrives, add it to the order at checkout, and send the order when it is created or changes status. Run the calls in the background (for example with Action Scheduler) so that checkout never waits for us.
<?php
// wp-config.php: define('QRONA_NEWSLETTER_KEY', 'qn_live_…'); define('QRONA_STORE_ID', 'butik-se');
const QRONA_API = 'https://newsletter.qrona.se/api/v1';
const QN_CID = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/';
// 1. The customer arrives from a mailing: store qn_cid for 30 days.
add_action('init', function () {
$cid = isset($_GET['qn_cid']) ? sanitize_text_field(wp_unslash($_GET['qn_cid'])) : '';
if (preg_match(QN_CID, $cid)) {
setcookie('qn_cid', $cid, time() + 30 * DAY_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true);
}
});
// 2. At checkout: the campaign goes with the order.
add_action('woocommerce_checkout_create_order', function ($order) {
$cid = isset($_COOKIE['qn_cid']) ? sanitize_text_field(wp_unslash($_COOKIE['qn_cid'])) : '';
if (preg_match(QN_CID, $cid)) {
$order->update_meta_data('_qn_cid', $cid);
}
});
// 3. When the order is created or changes status: send it in full, with the customer.
add_action('woocommerce_order_status_changed', function ($order_id) {
$order = wc_get_order($order_id);
$status = [
'processing' => 'paid', 'completed' => 'paid',
'refunded' => 'refunded', 'cancelled' => 'cancelled', 'failed' => 'cancelled',
][$order->get_status()] ?? 'pending';
$lines = [];
foreach ($order->get_items() as $item_id => $item) {
$lines[] = [
'id' => (string) $item_id,
'product_id' => (string) $item->get_product_id(),
'variant_id' => $item->get_variation_id() ? (string) $item->get_variation_id() : null,
'title' => $item->get_name(),
'quantity' => $item->get_quantity(),
'price' => round($item->get_total() / max(1, $item->get_quantity()), 2),
];
}
$email = $order->get_billing_email();
$body = [
'customer' => [
// Guests have no customer ID in WooCommerce: the hash of the address becomes their ID.
'id' => $order->get_customer_id() ? (string) $order->get_customer_id() : 'guest-' . md5(strtolower($email)),
'email' => $email,
'first_name' => $order->get_billing_first_name(),
'last_name' => $order->get_billing_last_name(),
],
'financial_status' => $status,
'currency' => $order->get_currency(),
'order_total' => round((float) $order->get_total(), 2),
'tax_total' => round((float) $order->get_total_tax(), 2),
'shipping_total' => round((float) $order->get_shipping_total(), 2),
'processed_at' => $order->get_date_created()->format(DATE_ATOM),
'lines' => $lines,
];
if ($order->get_meta('_qn_cid')) {
$body['campaign_id'] = $order->get_meta('_qn_cid');
}
wp_remote_request(QRONA_API . '/stores/' . QRONA_STORE_ID . '/orders/' . $order->get_id(), [
'method' => 'PUT',
'timeout' => 15,
'headers' => ['Authorization' => 'Bearer ' . QRONA_NEWSLETTER_KEY, 'Content-Type' => 'application/json'],
'body' => wp_json_encode($body),
]);
});Importing old orders
With POST /batch you can send 500 calls in one. Start with the products (if you want to be able to select them in conditions) and then the orders, with processed_at set to when they were placed. Read more under Batch and idempotency.
import { randomUUID } from 'node:crypto';
// Old orders, 500 at a time. PUT means an interrupted import can be rerun from
// the start without duplicates; Idempotency-Key means a batch that is resent
// (after a network error) gets the same response without being run again.
for (let i = 0; i < orders.length; i += 500) {
const operations = orders.slice(i, i + 500).map((o) => ({
method: 'PUT',
path: `/stores/butik-se/orders/${encodeURIComponent(o.id)}`,
body: toQronaOrder(o),
}));
const idempotencyKey = randomUUID();
let answer;
for (let attempt = 1; ; attempt++) {
const response = await fetch('https://newsletter.qrona.se/api/v1/batch', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.QRONA_NEWSLETTER_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({ operations }),
});
if (response.ok) { answer = await response.json(); break; }
if (attempt === 5 || (response.status !== 429 && response.status < 500)) throw new Error(`The batch returned ${response.status}`);
await new Promise((r) => setTimeout(r, Number(response.headers.get('retry-after') ?? 2 ** attempt) * 1000));
}
for (const [n, result] of answer.results.entries()) {
if (result.status >= 400) console.warn(operations[n].path, result.body.code, result.body.errors);
}
}