Post-Purchase Actions
Post-purchase actions run automatically when a payment has been confirmed. They can send a receipt, activate a course on Kajabi or Systeme.io, send a webhook to an external system, or send a customisable email to the customer.
Where Actions Can Be Configured
Actions are defined at three levels:
- Product — actions run whenever the product is included in an order
- Offer — actions run when an order is placed through this offer
- Checkout — actions run on all orders in the checkout
The same order can have actions from all three levels at once.
Action Types
Send Receipt by Email
Sends the customer an order confirmation by email. No additional settings required — the receipt is generated automatically from the order data.
Available at the checkout level.
A receipt is only sent if this action is active in the checkout's post-purchase actions. The action is added automatically when a new checkout is created, but it is not enabled by default — remember to activate it in the checkout settings.
Grant Kajabi Offer
Activates a Kajabi Offer for the customer by sending the customer's details to the offer's webhook URL.
Available at the product, offer, and checkout levels.
| Asetus | Kuvaus |
|---|---|
| Name | Internal name for the action. Only visible in the dashboard. |
| Webhook URL | The Kajabi integration webhook URL, found in the Kajabi dashboard. |
| Send offer grant email | When enabled, Kajabi sends the customer an email confirming the offer has been activated. |
| Hide last name | When enabled, only the first letter of the customer's last name is sent to Kajabi. Useful if customers interact in the Kajabi community and may not realise their full name is visible to others. |
Kajabi must be enabled in Settings → Integrations → Course Platforms before this action is available.
For detailed setup instructions, see: Kajabi Integration
Grant Systeme.io Access
Adds the customer as a contact in Systeme.io and applies one or more tags, triggering your Systeme.io automations to activate the course.
Available at the product, offer, and checkout levels.
| Asetus | Kuvaus |
|---|---|
| Name | Internal name for the action. Only visible in the dashboard. |
| Tags | The Systeme.io tags to add to the customer's contact after purchase. Each tag can trigger a separate automation. |
Systeme.io must be enabled with an API key in Settings → Integrations → Course Platforms before this action is available.
For detailed setup instructions, see: Systeme.io Integration
Webhook
Sends an HTTP POST request to a URL of your choice with the order data. Suitable for CRM systems, automation tools, or your own backend.
Available at the product, offer, and checkout levels.
| Asetus | Kuvaus |
|---|---|
| Name | Internal name for the action. Only visible in the dashboard. |
| URL | The endpoint to send the webhook request to. |
| Secret | An HMAC signing key you can use to verify the authenticity of incoming requests. Generated automatically. |
When the Request Is Sent
The request is sent once the order's payment has been confirmed. Each configured webhook action produces one request per order. Checkout members with the Admin, Maintainer or Customer Service role can also re-run the action from the order view, which sends the request again for the same order.
The Request
| Property | Value |
|---|---|
| Method | POST |
| Content-Type | application/json |
| Signature | Lowercase hex HMAC-SHA256 signature of the request |
| X-Webhook-Timestamp | Time the request was signed, in milliseconds since the Unix epoch |
The body is a single JSON object describing the order.
Payload
{
"id": "clw3x8k7t0001s0mn2f9qz1ab",
"status": "PAID",
"locale": "fi",
"totalPrice": "59.80",
"currency": "EUR",
"taxRegime": "VAT_CHARGED",
"updatedAt": "2026-02-17T09:41:12.883Z",
"billingInformation": {
"firstName": "Matti",
"lastName": "Meikäläinen",
"email": "matti@example.com",
"country": "FI",
"region": null
},
"products": [
{
"displayName": "Online Course: Introduction to Bookkeeping",
"description": "Six modules and a downloadable workbook.",
"imageUrl": "https://cdn.example.com/products/bookkeeping.jpg",
"originalPrice": "49.90",
"discountedPrice": "39.90",
"price": "39.90",
"vatRate": "0.255",
"vatAmount": "8.10",
"currency": "EUR",
"productType": "ONLINE_COURSE"
},
{
"displayName": "E-book: Bookkeeping Checklists",
"description": null,
"imageUrl": null,
"originalPrice": "19.90",
"discountedPrice": null,
"price": "19.90",
"vatRate": "0.1",
"vatAmount": "1.81",
"currency": "EUR",
"productType": "E_BOOK"
}
],
"payments": [
{
"stamp": "clw3x8k7t0002s0mn7d4x",
"transactionId": "4b8f21ce-6d0a-4c19-9a52-1f0e7d3c8b44",
"provider": "PAYTRAIL",
"method": "osuuspankki",
"status": "SUCCESS",
"processedAt": "2026-02-17T09:41:11.204Z"
}
]
}Conventions used throughout the payload:
- Every key listed below is always present. Nullable fields carry
nullwhen the value is unknown. - Monetary amounts and VAT rates are JSON strings, not JSON numbers.
- Monetary amounts always carry two decimals, for example
"39.90"and"25.00". - Prices include VAT.
vatAmountis the VAT already contained inprice. vatRateis a rate, not a percentage."0.255"means 25.5 %.- Timestamps are ISO 8601 strings in UTC.
Order
| Field | Type | Description |
|---|---|---|
| id | string | Identifier of the order. Stable across re-runs, so it doubles as the idempotency key for your handler. |
| status | string | Order status at the time of sending. PAID for automatically sent requests. A re-run carries the order's current status, which can also be COMPLETED, PARTIALLY_REFUNDED or REFUNDED. |
| locale | string | Language of the order as an ISO 639-1 code, e.g. fi. |
| totalPrice | string (decimal) | Total amount paid for the order, VAT included. |
| currency | string | One of EUR, SEK, USD, PLN, GBP. |
| taxRegime | string | How VAT was applied to the order. One of VAT_CHARGED, VAT_REVERSE_CHARGE, VAT_OUT_OF_SCOPE, VAT_NOT_REGISTERED. |
| updatedAt | string (ISO 8601) | When the order was last updated in the system. |
| billingInformation | object | Billing details given by the customer. |
| products | array | One entry per purchased line, including add-on sales. |
| payments | array | Successful payments recorded for the order. |
billingInformation
| Field | Type | Description |
|---|---|---|
| firstName | string | null | Customer's first name. |
| lastName | string | null | Customer's last name. |
| string | Customer's email address. Always collected at checkout. | |
| country | string | Billing country as an ISO 3166-1 alpha-2 code, e.g. FI. Always collected at checkout. |
| region | string | null | Regional subdivision of the billing address. Collected only for countries where it is required. |
products[]
Each entry is a snapshot of the product as it was at the time of purchase, in the order's language.
| Field | Type | Description |
|---|---|---|
| displayName | string | Product name shown to the customer. |
| description | string | null | Product description shown at checkout. |
| imageUrl | string | null | Absolute URL of the product image. |
| originalPrice | string (decimal) | Price before any discount, VAT included. |
| discountedPrice | string (decimal) | null | Price after discount, VAT included. Null when no discount was applied to the line. |
| price | string (decimal) | Amount actually charged for the line, VAT included. This is the value to use in your own bookkeeping. |
| vatRate | string (decimal) | VAT rate as a decimal fraction, e.g. 0.255. |
| vatAmount | string (decimal) | VAT contained in price. |
| currency | string | One of EUR, SEK, USD, PLN, GBP. |
| productType | string | One of ONLINE_COURSE, DIGITAL_CONTENT, E_BOOK, MEMBERSHIP, EVENT_TICKET, SERVICE. |
payments[]
Contains the successful payments of the order, most recent first. An order that required no payment, for example when a discount code covered the full price, has an empty array here.
| Field | Type | Description |
|---|---|---|
| stamp | string | Identifier generated by Verkkokurssikassa for the payment attempt. Unique across all payments. |
| transactionId | string | null | Identifier assigned by the payment provider. Use it to match the payment in the provider's own reporting. |
| provider | string | One of PAYTRAIL, EPASSI, SMARTUM, EDENRED, STRIPE. |
| method | string | null | Payment method reported by the provider, e.g. osuuspankki. Null when the provider reports no method. |
| status | string | Always SUCCESS in the payload. |
| processedAt | string (ISO 8601) | null | When the provider confirmed the payment. |
Verifying the Signature
Every request carries a Signature header. Recompute it at your end and compare it with the header value:
- Read the raw request body as text, before any JSON parsing.
- Build the signed string: the value of the
X-Webhook-Timestampheader, a literal period, then the raw body. For example1771320072883.{"id":"clw3x8k7t0001s0mn2f9qz1ab",...}. - Compute HMAC-SHA256 over that string using the action's secret as the key, encoded as lowercase hex.
- Compare the result with the
Signatureheader using a constant-time comparison.
The signature covers the raw body byte for byte. Frameworks that parse the JSON and re-serialise it produce different bytes and the comparison will fail, so capture the raw body before parsing.
import { createHmac, timingSafeEqual } from "node:crypto";
const secret = process.env.WEBHOOK_SECRET;
if (!secret) throw new Error("WEBHOOK_SECRET is not set");
export async function POST(request: Request) {
const rawBody = await request.text();
const timestamp = request.headers.get("X-Webhook-Timestamp");
const signature = request.headers.get("Signature");
if (!timestamp || !signature) {
return new Response("Missing signature headers", { status: 400 });
}
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const received = Buffer.from(signature, "hex");
const computed = Buffer.from(expected, "hex");
if (received.length !== computed.length || !timingSafeEqual(received, computed)) {
return new Response("Invalid signature", { status: 401 });
}
const order = JSON.parse(rawBody);
// Handle the order here.
return new Response(null, { status: 200 });
}What Your Endpoint Should Do
- Verify the signature before trusting the payload.
- Respond with a 2xx status once you have handled the request. Doing the work in the handler and responding after it succeeds is fine, and it means a failure on your side is visible: any other status marks the action Failed in the order view, where it can be re-run.
- Respond within 30 seconds. A request that takes longer is cancelled and the action is marked Failed, so move work that cannot finish in time into a background job of your own and answer before the limit.
- Treat
idas the idempotency key. The same order can reach your endpoint more than once, for example when the action is re-run.
Send Email
Sends a customisable email to the customer after purchase.
Available at the product, offer, and checkout levels.
The subject and content are configured separately for each language supported by the checkout.
| Asetus | Kuvaus |
|---|---|
| Name | Internal name for the action. Only visible in the dashboard. |
| Subject | The email's subject line. |
| Email content | The email body, built from image, rich-text, and banner blocks. |
If the order's language has no configured content, the email is sent using the checkout's first supported language. If that language also has no content, the email is skipped and the action is still marked Succeeded — make sure every supported language has a subject and body to avoid silent skips.
Action Status
The execution status of each action is visible in the order details in the dashboard. Possible statuses:
- Pending — the action has not yet been executed
- Running — the action is currently being executed
- Succeeded — the action completed successfully
- Failed — the action execution failed
Re-running Individual Actions
Each action row in the order sheet has a re-run button. This lets you re-trigger a single action without touching the others — useful when a customer needs the receipt resent, or when a single integration call failed and you want to retry it after fixing the configuration.
Open an order, expand the Post-purchase Actions section, and click the re-run icon next to the action you want to fire again. A dialog will appear summarising what is about to happen.
Use Latest Template
The dialog has a Use latest template option. When enabled, the action's stored snapshot is replaced with the current template configuration before re-running. This is the right choice when you fixed a setting (e.g. the Kajabi offer URL or a webhook endpoint) and want the re-run to use the new value.
When disabled, the action runs with the configuration that was captured at the time of the original purchase.
Replacing the snapshot is permanent — the previous configuration is overwritten and cannot be recovered.
Re-running a Successful Action
You can also re-run actions that already succeeded. The dialog shows a warning when this is the case, because the action's side effects will fire again — for example, the customer will receive a second receipt email or your webhook endpoint will be called again. Use this when a customer asks for the receipt to be resent, or when downstream systems lost the original delivery.
Force Re-run
If an action is stuck in the Running state — typically because the previous run crashed mid-execution and never recorded a final outcome — the re-run button changes to a Force re-run affordance with a stronger warning. Force re-run bypasses the in-progress safeguard. Use it only when you are confident the previous run is no longer active, since two simultaneous executions could produce duplicate side effects.
Re-running an action does not change the order's status by itself, but if the re-run was the last failing piece, the order will automatically transition to Completed.