Events API and Webhooks [Beta]

Learn how to receive and query events.

Webhooks are real-time alerts about API events that are sent as they happen. With Lithic Events, you can register and manage webhook URLs, replay webhook messages, control event subscription secrets, and search past events.

Event Subscription Schema

{
    "description": "Example Event Subscription",
    "token": "ep_1srOrx2ZWZBpBUvZwXKQmoEYga1",
    "event_types": null,
    "disabled": false,
    "url": "https://www.lithic.com/webhooks"
}
descriptionEvent subscription description. String.
event_typesIndicates types of events that will be sent to this subscription. If left blank, all types will be sent. See Event Types for complete list. String.
disabledRepresents whether the event subscription is active (false) or inactive (true). Boolean.
urlURL to which event webhooks will be sent. URL must be a valid HTTPS address. String.
tokenUnique identifier for event subscription. String.

Create Event Subscription

POST https://api.lithic.com/v1/event_subscriptions

Sample Request

curl https://api.lithic.com/v1/event_subscriptions \
    -X POST \
    -H 'Authorization: YOUR_API_KEY' \
    -H 'accept: application/json' \
    -H 'content-type: application/json' \
    -d '  
{  
    "url": "https://www.lithic.com/webhooks",
    "description": "Subscription to all event types"
}  
'

Sample Response

{  
    "description": "Subscription to all event types",  
    "token": "ep_1srOrx2ZWZBpBUvZwXKQmoEYga1",  
    "event_types": null,  
    "disabled": false,  
    "url": "https://www.lithic.com/webhooks",
    "debugging_request_id": "2b736cba-a7a7-4036-8454-0d48a2cc15ed"  
}
description (optional)Event subscription description. String.
event_types (optional)Indicates types of events that will be sent to this subscription. If left blank, all types will be sent. String.
disabled (optional)Represents whether the event subscription is active (false) or inactive (true). Boolean.
url (required)URL to which event webhooks will be sent. URL must be a valid HTTPS address. String.

List Event Subscriptions

GET https://api.lithic.com/v1/event_subscriptions

Sample Request

curl https://api.lithic.com/v1/event_subscriptions \
    -H 'Authorization: YOUR_API_KEY' \
    -H 'accept: application/json'

Sample Response

{
  "data": [
    {
      "description": "First Event Subscription",
      "token": "ep_1srOrx2ZWZBpBUvZwXKQmoEYga1",
      "event_types": null,
      "disabled": false,
      "url": "https://www.lithic.com/webhooks"
    }
  ],
  "has_more": false
}
page_size (optional, query parameter)For pagination - specifies the number of entries to be included on each page in the response. Default value is 50.
Integer. Permitted values: 1-100.
starting_after (optional, query parameter)For pagination - event subscriptions created after the specified event token will be included. String.
ending_before (optional, query parameter)For pagination - event subscriptions created before the specified event token will be included. String.

Get Event Subscription

GET https://api.lithic.com/v1/event_subscriptions/{event_subscription_token}

Sample Request:

curl https://api.lithic.com/v1/event_subscriptions/ep_1srOrx2ZWZBpBUvZwXKQmoEYga1 \
    -H 'Authorization: YOUR_API_KEY' \
    -H 'accept: application/json'

Sample Response:

{
    "description": "First Event Subscription",
    "token": "ep_1srOrx2ZWZBpBUvZwXKQmoEYga1",
    "event_types": null,
    "disabled": false,
    "url": "https://www.lithic.com/webhooks"
}
event_subscription_token (required, path parameter)Globally unique identifier for the event subscription.
String.

Update Event Subscription

PATCH https://api.lithic.com/v1/event_subscriptions/{event_subscription_token}

Sample Request

curl --request PATCH \
     --url https://api.lithic.com/v1/event_subscriptions/ep_1srOrx2ZWZBpBUvZwXKQmoEYga1 \
     --header 'Authorization: 1fe14193-bf47-4336-8366-f8680d6e9250' \
     --header 'accept: application/json' \
     --header 'content-type: application/json' \
     --data '
{
     "description": "Updated Event Subscription",
     "disabled": true,
     "url": "https://www.lithic.com/webhooks2"
}
'

Sample Response

{
  "description": "Updated Event Subscription",
  "token": "ep_1srOrx2ZWZBpBUvZwXKQmoEYga1",
  "event_types": null,
  "disabled": true,
  "url": "https://www.lithic.com/webhooks2"
}
event_subscription_token (required, path parameter)Globally unique identifier for the event subscription to be updated.
String.
disabled (optional)Represents whether the event subscription is active (false) or inactive (true). Boolean.
event_types (optional)Indicates types of events will be sent to this subscription. If not specified, all types will be sent.
url (required)URL to which event webhooks will be sent. URL must be a valid HTTPS address. String.
description (optional)Description for your event subscription. We recommend against using this field to store JSON data as it can cause unexpected behavior. String.

Delete Event Subscription

DELETE https://api.lithic.com/v1/event_subscriptions/{event_subscription_token}

Sample Request

curl https://api.lithic.com/v1/event_subscriptions/ep_1srOrx2ZWZBpBUvZwXKQmoEYga1 \
  -X DELETE \
  -H 'Authorization: YOUR_API_KEY' \
  -H 'content-type: application/json'

Sample Response

No Content
event_subscription_token (required, path parameter)Globally unique identifier for the event subscription to be deleted.
String.

Resend Failed Messages

Resend all failed messages since a given time. Lithic marks non-2XX responses as failed and retry.

POST https://api.lithic.com/v1/event_subscriptions/{event_subscription_token}/recover

Sample Request

curl https://api.lithic.com/v1/event_subscriptions/ep_1srOrx2ZWZBpBUvZwXKQmoEYga1/recover \
  -X POST \
  -H 'Authorization: YOUR_API_KEY' \
  -H 'accept: application/json' \
  -H 'content-type: application/json' \
  -d '{
    "begin": "2023-01-01T14:15:00Z",
    "end": "2023-01-20T14:15:00Z"
}'

Sample Response

No Content
event_subscription_token (required, path parameter)Globally unique identifier for the event subscription whose messages you want resent.
String.
begin (optional, query parameter)Events created on or after the specified date will be included.
String .
end (optional, query parameter)Events created before the specified date will be included (i.e., events created on the specified date will not be included).
String.

Replay Missing Messages

Replay messages to the endpoint. Only messages that were created after begin will be sent. Messages that were previously sent to the endpoint are not resent.

GET https://api.lithic.com/v1/event_subscriptions/{event_subscription_token}/replay_missing

Sample Request

curl 'https://api.lithic.com/v1/event_subscriptions/ep_1srOrx2ZWZBpBUvZwXKQmoEYga1/replay_missing' \
  -X 'POST' \
  -H 'Authorization: YOUR_API_KEY' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
    "begin": "2023-01-01T14:15:00Z",
    "end": "2023-01-20T14:15:00Z"
}'

Sample Response

No Content
event_subscription_token (required, path parameter)Globally unique identifier for the event subscription whose messages you want resent.
String.
begin (optional, query parameter)Events created on or after the specified date will be included.
String .
end (optional, query parameter)Events created before the specified date will be included (i.e., events created on the specified date will not be included).
String.

Get Event Subscription Secret

GET https://api.lithic.com/v1/event_subscriptions/{event_subscription_token}/secret

Sample Request

curl https://api.lithic.com/v1/event_subscriptions/ep_1srOrx2ZWZBpBUvZwXKQmoEYga1/secret \
  -H 'Authorization: YOUR_API_KEY' \
  -H 'accept: application/json'

Sample Response

{
    "key": "whsec_qhVbLvxSzK3OyH4baRAKDUGm3az3bduK"
}
event_subscription_token (required, path parameter)Globally unique identifier for the event subscription whose secret you want to retrieve.
String.

Rotate Event Subscription Secret

📘

The previous secret will be valid for the next 24 hours. See Validating Webhooks for more information on how to validate signatures.

POST https://api.lithic.com/v1/event_subscriptions/{event_subscription_token}/secret/rotate

Sample Request

curl https://api.lithic.com/v1/event_subscriptions/ep_1srOrx2ZWZBpBUvZwXKQmoEYga1/secret/rotate \
    -X POST \
    -H 'Authorization: YOUR_API_KEY' \
    -H 'content-type: application/json'

Sample Response

No Content
event_subscription_token (required, path parameter)Globally unique identifier for the event subscription whose secret you want to rotate.
String.

List All Events

List events, going back up to 90 days.

GET https://api.lithic.com/v1/events

Sample Request

curl 'https://api.lithic.com/v1/events' \
 -H 'Authorization: YOUR_API_KEY' \
 -H 'accept: application/json'

Sample Response

{
    "data": [...],
    "has_more": false
}
page_size (optional, query parameter)For pagination - specifies the number of entries to be included on each page in the response. Default value is 50.
Integer. Permitted values: 1-1000.
starting_after (optional, query parameter)For pagination - events created after the specified event token will be included.
String.
ending_before (optional, query parameter)For pagination - Events created before the specified event token will be included.
String.
begin (optional, query parameter)Events created on or after the specified date will be included.
String.
end (optional, query parameter)Events created before the specified date will be included (i.e., events created on the specified date will not be included).
String.
event_types (optional, query parameter)Event types to filter by. Note you must pass event types as an array, i.e. event_types[]=foo.

Get Specific Event

GET https://api.lithic.com/v1/events/{event_token}

Sample Request

curl https://api.lithic.com/v1/events/msg_1srOrx2ZWZBpBUvZwXKQmoEYga1 \
    -H 'Authorization: YOUR_API_KEY' \
    -H 'accept: application/json'

Sample Response

{
    "token": "msg_1srOrx2ZWZBpBUvZwXKQmoEYga1",
    "event_type": "dispute.updated",
    "payload": {...},
    "created": "2022:10:10T12:31:12Z"
}
event_token (required, path parameter)Globally unique identifier for the event you want to retrieve.
String.

Verifying Webhooks

📘

The recommended way to verify webhooks is using our official libraries. See the README of their repositories for how to use them to verify webhooks.

Each webhook call includes three headers that can be used for verification:

  • webhook-id: Identifier for the webhook message that distinguishes it from other messages. This identifier will remain unchanged if the same webhook message is resent (e.g., due to a previous failure).
  • webhook-timestamp: Unix time.
  • webhook-signature: A list of signatures encoded in Base64 and separated by spaces. Can be multiple as a result of rotation.

Constructing the signed content

The content to sign is composed by concatenating the id, timestamp and payload, separated by the full-stop character (.). In code, it will look something like:

signedContent = "${webhookId}.${webhookTimestamp}.${body}"

Here, body refers to the raw body of the request.

Calculate the expected signature

Lithic uses an HMAC with SHA-256 to sign its webhooks.

Calculate the expected signature by applying HMAC to the signedContent using the Base64 part of your signing secret, which comes after the whsec_ prefix, as the key. For instance, with the secret whsec_uV7N6t5FJWzsRxkTQA9MKDYh1BgBgDcR, you should use uV7N6t5FJWzsRxkTQA9MKDYh1BgBgDcR as the key.

This generated signature should match one of the ones sent in the webhook-signature header.

The webhook-signature header is composed of a list of space delimited signatures and their corresponding version identifiers. Multiple signatures can be included in the event of say a secret rotation.

Example header with multiple signature

v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= v1,bm9ldHUjKzFob2VudXRob2VodWUzMjRvdWVvdW9ldQo=

🚧

Make sure to remove the version prefix and delimiter (e.g. v1,) before verifying the signature.

Verify timestamp

It's recommended to use a constant-time string comparison method in order to prevent timing attacks.

As mentioned above, Lithic also sends the timestamp of the attempt in the webhook-timestamp header. You should compare this timestamp against your system timestamp and make sure it's within your tolerance in order to prevent replay attacks.

Example code

@app.post('/my-webhook-handler')
async def handler(request: Request):
    body = await request.body()
    secret = os.environ['LITHIC_WEBHOOK_SECRET']  # env var used by default; explicit here.
    payload = client.webhooks.unwrap(body, request.headers, secret)
    print(payload)
export default function handler(req, res) {
  const payload = lithic.webhooks.unwrap(req.body, req.headers, process.env['LITHIC_WEBHOOK_SECRET']); // env var used by default; explicit here.
  console.log(payload);
}
import base64
import hashlib
import hmac
import json
import typing as t
from datetime import datetime, timedelta, timezone
from math import floor


class WebhookVerificationError(Exception):
    pass


def hmac_data(key: bytes, data: bytes) -> bytes:
    return hmac.new(key, data, hashlib.sha256).digest()

  
def verify_timestamp(timestamp_header: str) -> str:
    webhook_tolerance = timedelta(minutes=5)
    now = datetime.now(tz=timezone.utc)
    try:
        timestamp = datetime.fromtimestamp(float(timestamp_header), tz=timezone.utc)
    except Exception:
        raise WebhookVerificationError("Invalid Signature Headers")

    if timestamp < (now - webhook_tolerance):
        raise WebhookVerificationError("Message timestamp too old")
    if timestamp > (now + webhook_tolerance):
        raise WebhookVerificationError("Message timestamp too new")
    return timestamp_header  


def sign_webhook(whsecret: bytes, msg_id: str, timestamp: str, data: str) -> str:
    to_sign = f"{msg_id}.{timestamp_str}.{data}".encode()
    signature = hmac_data(whsecret, to_sign)
    return f"v1,{base64.b64encode(signature).decode('utf-8')}"
  

def verify_webhook(whsecret: str, data: bytes, headers: t.Dict[str, str]) -> bool:
    SECRET_PREFIX = "whsec_"
    if whsecret.startswith(SECRET_PREFIX):
        whsecret = whsecret[len(SECRET_PREFIX) :]
    whsecret = base64.b64decode(whsecret)

    headers = {k.lower(): v for (k, v) in headers.items()}
    msg_id = headers.get("webhook-id")
    msg_signature = headers.get("webhook-signature")
    msg_timestamp = headers.get("webhook-timestamp")

    if not (msg_id and msg_timestamp and msg_signature):
        raise WebhookVerificationError("Missing required headers")
    
    timestamp = verify_timestamp(msg_timestamp)
    expected_sig = base64.b64decode(sign_webhook(whsecret, msg_id, timestamp, data).split(",")[1])
    
    # Signature header can contain multiple signatures delimited by spaces
    passed_sigs = msg_signature.split(" ")

    for versioned_sig in passed_sigs:
        (version, signature) = versioned_sig.split(",")
        # Only verify prefix v1
        if version != "v1":
            continue
        sig_bytes = base64.b64decode(signature)
        if hmac.compare_digest(expected_sig, sig_bytes):
            return True

    raise False
const WEBHOOK_TOLERANCE_IN_SECONDS = 5 * 60; // 5 minutes

class ExtendableError extends Error {
    constructor(message: any) {
        super(message);
        Object.setPrototypeOf(this, ExtendableError.prototype);
        this.name = "ExtendableError";
        this.stack = new Error(message).stack;
    }
}

export class WebhookVerificationError extends ExtendableError {
    constructor(message: string) {
        super(message);
        Object.setPrototypeOf(this, WebhookVerificationError.prototype);
        this.name = "WebhookVerificationError";
    }
}

export interface WebhookRequiredHeaders {
    "webhook-id": string;
    "webhook-timestamp": string;
    "webhook-signature": string;
}

export interface WebhookOptions {
    format?: "raw";
}

/** Make an assertion, if not `true`, then throw. */
function assert(expr: unknown, msg = ""): asserts expr {
    if (!expr) {
        throw new Error(msg);
    }
}

/** Compare to array buffers or data views in a way that timing based attacks
 * cannot gain information about the platform. */
function timingSafeEqual(
    a: ArrayBufferView | ArrayBufferLike | DataView,
    b: ArrayBufferView | ArrayBufferLike | DataView
): boolean {
    if (a.byteLength !== b.byteLength) {
        return false;
    }
    if (!(a instanceof DataView)) {
        a = new DataView(ArrayBuffer.isView(a) ? a.buffer : a);
    }
    if (!(b instanceof DataView)) {
        b = new DataView(ArrayBuffer.isView(b) ? b.buffer : b);
    }
    assert(a instanceof DataView);
    assert(b instanceof DataView);
    const length = a.byteLength;
    let out = 0;
    let i = -1;
    while (++i < length) {
        out |= a.getUint8(i) ^ b.getUint8(i);
    }
    return out === 0;
}

export class Webhook {
    private static prefix = "whsec_";
    private readonly key: Uint8Array;

    constructor(secret: string | Uint8Array, options?: WebhookOptions) {
        if (!secret) {
            throw new Error("Secret can't be empty.");
        }
        if (options?.format === "raw") {
            if (secret instanceof Uint8Array) {
                this.key = secret;
            } else {
                this.key = Uint8Array.from(secret, (c) => c.charCodeAt(0));
            }
        } else {
            if (typeof secret !== "string") {
                throw new Error("Expected secret to be of type string");
            }
            if (secret.startsWith(Webhook.prefix)) {
                secret = secret.substring(Webhook.prefix.length);
            }
            this.key = base64.decode(secret);
        }
    }

    public verify(
        payload: string,
        headers_:
            | WebhookRequiredHeaders
            | Record<string, string>
    ): unknown {
        const headers: Record<string, string> = {};
        for (const key of Object.keys(headers_)) {
            headers[key.toLowerCase()] = (headers_ as Record<string, string>)[key];
        }

        let msgId = headers["webhook-id"];
        let msgSignature = headers["webhook-signature"];
        let msgTimestamp = headers["webhook-timestamp"];

        if (!msgSignature || !msgId || !msgTimestamp) {
            throw new WebhookVerificationError("Missing required headers");
        }

        const timestamp = this.verifyTimestamp(msgTimestamp);

        const computedSignature = this.sign(msgId, timestamp, payload);
        const expectedSignature = computedSignature.split(",")[1];

        const passedSignatures = msgSignature.split(" ");

        const encoder = new globalThis.TextEncoder();
        for (const versionedSignature of passedSignatures) {
            const [version, signature] = versionedSignature.split(",");
            if (version !== "v1") {
                continue;
            }

            if (timingSafeEqual(encoder.encode(signature), encoder.encode(expectedSignature))) {
                return JSON.parse(payload);
            }
        }
        throw new WebhookVerificationError("No matching signature found");
    }

    public sign(msgId: string, timestamp: Date, payload: string): string {
        const encoder = new TextEncoder();
        const toSign = encoder.encode(`${msgId}.${timestamp.getTime() / 1000}.${payload}`);
        const expectedSignature = base64.encode(sha256.hmac(this.key, toSign));
        return `v1,${expectedSignature}`;
    }

    private verifyTimestamp(timestampHeader: string): Date {
        const now = Math.floor(Date.now() / 1000);
        const timestamp = parseInt(timestampHeader, 10);
        if (isNaN(timestamp)) {
            throw new WebhookVerificationError("Invalid Signature Headers");
        }

        if (now - timestamp > WEBHOOK_TOLERANCE_IN_SECONDS) {
            throw new WebhookVerificationError("Message timestamp too old");
        }
        if (timestamp > now + WEBHOOK_TOLERANCE_IN_SECONDS) {
            throw new WebhookVerificationError("Message timestamp too new");
        }
        return new Date(timestamp * 1000);
    }
}

Retry Schedule

Each message is attempted based on the following schedule, where each period is started following the failure of the preceding attempt:

Immediate  → +5s  → +5m  → +30m  → +2h  → +5h  → +10h  → +10h (additional)

(s = seconds, m = minutes, h = hours)

If an endpoint is removed or disabled delivery attempts to the endpoint will be disabled as well.

For example, an attempt that fails three times before eventually succeeding will be delivered roughly 35 minutes and 5 seconds following the first attempt.

Manual retries

You can also manually retry each message at any time, or automatically retry ("recover") all failed messages starting from a given date.

  • Resend webhook for retrying a single message.
  • Resend failed webhooks for the failed messages recovery.

Event Types

Event TypeDescription
digital_wallet.token_approval_requestCard network's request to Lithic to activate a digital wallet token. See Digital Wallet Events.
digital_wallet.activation_notification (1H 2023)Notification of a successfully activated digital wallet token.
digital_wallet.tokenization_auth_code (1H 2023)A code to be passed to an end user to complete digital wallet authentication.
dispute.updatedDispute is updated
card_transaction.updated (1H 2023)Transaction lifecycle event