Verkkokurssikassa
Basics

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.

AsetusKuvaus
NameInternal name for the action. Only visible in the dashboard.
Webhook URLThe Kajabi integration webhook URL, found in the Kajabi dashboard.
Send offer grant emailWhen enabled, Kajabi sends the customer an email confirming the offer has been activated.
Hide last nameWhen 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.

AsetusKuvaus
NameInternal name for the action. Only visible in the dashboard.
TagsThe 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.

AsetusKuvaus
NameInternal name for the action. Only visible in the dashboard.
URLThe endpoint to send the webhook request to.
SecretAn 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

PropertyValue
MethodPOST
Content-Typeapplication/json
SignatureLowercase hex HMAC-SHA256 signature of the request
X-Webhook-TimestampTime 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 null when 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. vatAmount is the VAT already contained in price.
  • vatRate is a rate, not a percentage. "0.255" means 25.5 %.
  • Timestamps are ISO 8601 strings in UTC.
Order
FieldTypeDescription
idstringIdentifier of the order. Stable across re-runs, so it doubles as the idempotency key for your handler.
statusstringOrder 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.
localestringLanguage of the order as an ISO 639-1 code, e.g. fi.
totalPricestring (decimal)Total amount paid for the order, VAT included.
currencystringOne of EUR, SEK, USD, PLN, GBP.
taxRegimestringHow VAT was applied to the order. One of VAT_CHARGED, VAT_REVERSE_CHARGE, VAT_OUT_OF_SCOPE, VAT_NOT_REGISTERED.
updatedAtstring (ISO 8601)When the order was last updated in the system.
billingInformationobjectBilling details given by the customer.
productsarrayOne entry per purchased line, including add-on sales.
paymentsarraySuccessful payments recorded for the order.
billingInformation
FieldTypeDescription
firstNamestring | nullCustomer's first name.
lastNamestring | nullCustomer's last name.
emailstringCustomer's email address. Always collected at checkout.
countrystringBilling country as an ISO 3166-1 alpha-2 code, e.g. FI. Always collected at checkout.
regionstring | nullRegional 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.

FieldTypeDescription
displayNamestringProduct name shown to the customer.
descriptionstring | nullProduct description shown at checkout.
imageUrlstring | nullAbsolute URL of the product image.
originalPricestring (decimal)Price before any discount, VAT included.
discountedPricestring (decimal) | nullPrice after discount, VAT included. Null when no discount was applied to the line.
pricestring (decimal)Amount actually charged for the line, VAT included. This is the value to use in your own bookkeeping.
vatRatestring (decimal)VAT rate as a decimal fraction, e.g. 0.255.
vatAmountstring (decimal)VAT contained in price.
currencystringOne of EUR, SEK, USD, PLN, GBP.
productTypestringOne 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.

FieldTypeDescription
stampstringIdentifier generated by Verkkokurssikassa for the payment attempt. Unique across all payments.
transactionIdstring | nullIdentifier assigned by the payment provider. Use it to match the payment in the provider's own reporting.
providerstringOne of PAYTRAIL, EPASSI, SMARTUM, EDENRED, STRIPE.
methodstring | nullPayment method reported by the provider, e.g. osuuspankki. Null when the provider reports no method.
statusstringAlways SUCCESS in the payload.
processedAtstring (ISO 8601) | nullWhen 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:

  1. Read the raw request body as text, before any JSON parsing.
  2. Build the signed string: the value of the X-Webhook-Timestamp header, a literal period, then the raw body. For example 1771320072883.{"id":"clw3x8k7t0001s0mn2f9qz1ab",...}.
  3. Compute HMAC-SHA256 over that string using the action's secret as the key, encoded as lowercase hex.
  4. Compare the result with the Signature header 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 id as 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.

AsetusKuvaus
NameInternal name for the action. Only visible in the dashboard.
SubjectThe email's subject line.
Email contentThe 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.

On this page