Invincible Pay
Webhook

Introduction

This page describes what webhooks you can receive from our backend, and how to validate that the webhook is indeed coming from our backend.

URL restrictions

Please make sure that the URLs you create are globally routable. Don't use private IP addresses since they won't be reachable.

Our webhook system doesn't follow redirects, so if your backend responds with a 3xx response, it won't be followed. Make sure to process the webhooks at the original provided address.

Authentication

We use ED25519 to sign all our requests. You can use the following public key to verify our requests: MCowBQYDK2VwAyEAkXeRyk6QO8yGBkuX5eDiLvdHOWZ8QaiadNJhFYwZfAk=. The key is in SPKI DER format.

Each webhook should reach your endpoints with the following headers:

  • X-Webhook-Id - The unique ID of the webhook. Useful for deduplicating webhooks, idempotency, or simply debugging
  • X-Timestamp - The Unix UTC timestamp (milliseconds) measured on our backend when the webhook was sent
  • X-Webhook-Signature - The ED25519 signature of the webhook request.

The signature is generated based on the components of the request:

  • X-Timestamp
  • HTTP method in uppercase format. This is going to be POST for our webhooks.
  • Request path, excluding the query string: /users, /transactions/send
  • Query string, if present. Including the ? symbol: ?amount=5&status=pending. This is useful if the webhook URL you provided to our backend contains a query string for some reason
  • HTTP body

Example

So for example, you add a webhook URL https://backend.com/my-webhook?source=inv. The query string is to demonstrate how signatures act, usually it's quite unexpected to have a query string as part of a webhook URL but it is supported. If our backend sent you a webhook fro the checkoutCompleted event, the signature would be calculated as follows:

  • timestamp = 1780011704561
  • method = POST
  • path = /my-webhook
  • query = ?source=inv
  • body = {"type": "checkoutCompleted","data": {id: "123"}}
Signature = sign(`1780011704561POST/my-webhook?source=inv{"type": "checkoutCompleted","data": {id: "123"}}`);

Example code

Below is an example verification process of our webhooks using Node.js, TypeScript, and Express.js. The gist lies in the verifySignature method.

import { createPublicKey, verify } from "crypto";
import express, { json, Request, Response } from "express";
import { URL } from "url";

const app = express();

const publicKey = createPublicKey({
  key: Buffer.from(
    "MCowBQYDK2VwAyEAkXeRyk6QO8yGBkuX5eDiLvdHOWZ8QaiadNJhFYwZfAk=",
    "base64"
  ),
  format: "der",
  type: "spki",
});

function verifySignature(req: Request, body: Buffer) {
  const timestamp = req.headers["x-timestamp"];
  const signatureB64 = req.headers["x-webhook-signature"];

  if (!signatureB64 || typeof signatureB64 !== "string") {
    throw new Error("Signature is missing from the request");
  }

  // You can also validate the timestamp, for example to avoid old stray webhooks
  if (!timestamp || typeof timestamp !== "string") {
    throw new Error("Timestamp is missing from the request");
  }

  // Reconstruct the full URL
  const host = req.headers["host"] ?? "";
  const url = new URL(`${req.protocol}://${host}${req.originalUrl}`);

  // Replicate the steps performed when generating the signature
  const components = [timestamp, req.method, url.pathname, url.search].map(
    Buffer.from
  );

  const valid = verify(
    null,
    Buffer.concat([...components, body]),
    publicKey,
    Buffer.from(signatureB64, "base64")
  );

  if (!valid) {
    throw new Error("Invalid signature");
  }
}

function webhookSignatureBodyVerifier(
  req: Request,
  res: Response,
  buf: Buffer<ArrayBufferLike>
) {
  try {
    verifySignature(req, buf);
  } catch (e) {
    res.status(403).send({
      error: (e as Error).message,
    });
    throw e;
  }
}

app.post(
  "/webhook",
  json({
    verify: webhookSignatureBodyVerifier,
  }),
  (req, res) => {
    console.log(req.headers, req.body);
    res.send({
      ok: true,
    });
  }
);

app.listen(8000);

Webhook types

Below are the type of webhooks our system sends. If you notice a type or a status enum that is not defined on this page, it can be located on a related API page.

checkoutCompleted

This webhook notification is sent when a checkout is successfully completed.

{
  type: "checkoutCompleted";
  data: {
    id: string;
  }
}

checkoutExpired

This webhook notification is sent when a checkout expires before being completed.

{
  type: "checkoutExpired";
  data: {
    id: string;
  }
}

transactionCreated

This webhook notification is sent when a new transaction is created.

{
  type: "transactionCreated";
  data: {
    id: string;
  }
}

transactionUpdated

This webhook notification is sent when a transaction status or details are updated.

{
  type: "transactionUpdated";
  data: {
    id: string;
    rejectionReason?: string | null;
    status: TransactionStatusDto;
  };
}

feeTransactionCreated

This webhook notification is sent when a fee transaction is created.

{
  type: "feeTransactionCreated";
  data: {
    id: string;
  }
}

manualEtransferCreated

This webhook notification is sent when a manual e-transfer transaction is created.

{
  type: "manualEtransferCreated";
  data: {
    id: string;
  }
}

manualEtransferUpdated

This webhook notification is sent when a manual e-transfer transaction is updated.

{
  type: "manualEtransferUpdated";
  data: {
    id: string;
    lastModifiedAt: number;
    status: ManualEtransferStatusDto;
  }
}

paymentLinkCompleted

This webhook notification is sent when a payment link is completed.

{
  type: "paymentLinkCompleted";
  data: {
    id: string;
  }
}

paymentLinkExpired

This webhook notification is sent when a payment link expires.

{
  type: "paymentLinkExpired";
  data: {
    id: string;
  }
}

On this page