Transaction Webhooks

Learn about transaction webhooks.

Transaction webhooks can now be sent via the Events API in addition to our existing webhooks functionality so that you can replay transaction webhooks, manage/rotate subscription-specific secrets, and query transaction events. See our Events API and Webhooks guide here.

For transaction webhook URLs set up via Lithic Dashboard (instead of Events), we'll send an HTTP POST request to your specified transactions webhook url (set in your Dashboard’s Account Settings page) whenever a transaction lifecycle event occurs. We recommend responding to transactions webhooks with a 200 at time of receipt. If there is no response or a non-200 response, Lithic will retry sending the webhook. Retries are attempted with exponential backoff, starting with the first retry 5 minutes after the webhook and ending when the next attempt would be over a day.

📘

In Sandbox, users can create a transaction webhook by adding a URL on the account settings page of their Lithic account. After logging in, use the dropdown and select Account. On the Account Settings page under Enable API, select Enable Sandbox Webhook and enter a URL.

Message Types

Transaction Events are generated for the following actions:

AuthorizationThe API sends an event for all approvals. Decline events are available with API Issuing accounts
Auth AdviceTransaction was declined by an upstream switch or a previous authorization's amount is being adjusted
VoidPrevious pending authorization is voided
ClearingClearing for an existing, pending authorization
ReturnCredit — value is pushed onto card

Webhook Verification

📘

NEW: Note that if you are receiving your transaction events via the Events API, webhook verification will work differently. See the Events API and Webhooks documentation.

To verify that the request is legitimate, you may generate an HMAC of the transaction object and compare it with the one included in the x-lithic-hmac request header.

See below for example implementations of verifying a webhook request.

import base64
import hashlib
import hmac
import json


def to_json_str(json_object):
    return json.dumps(json_object, sort_keys=True, separators=(',', ':'))


def hmac_signature(key, msg):
    hmac_buffer = hmac.new(
        key=bytes(key, 'utf-8'),
        msg=bytes(msg, 'utf-8'),
        digestmod=hashlib.sha256
    )
    return base64.b64encode(hmac_buffer.digest()).decode('utf-8')


def request_is_from_lithic(api_key, transaction, request_headers):
    request_hmac = request_headers["x-lithic-hmac"]
    data_hmac = hmac_signature(api_key, to_json_str(transaction))

    return hmac.compare_digest(request_hmac, data_hmac)
const crypto = require("crypto");


function hmacSignature(key, msg) {
    return crypto.createHmac("sha256", key)
                 .update(msg)
                 .digest("base64");
}

function requestIsFromLithic(apiKey, transaction, requestHeaders) {
  const requestHmac = requestHeaders["x-lithic-hmac"];

    const replacer = (key, value) =>
    value instanceof Object && !(value instanceof Array) ? 
      Object.keys(value)
      .sort()
      .reduce((sorted, key) => {
        sorted[key] = value[key];
        return sorted 
      }, {}) :
      value;  

  const requestJson = JSON.stringify(transaction, replacer);
  const dataHmac = hmacSignature(apiKey, requestJson);

  return crypto.timingSafeEqual(Buffer.from(requestHmac), Buffer.from(dataHmac));
}
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;

public class HmacTester {
    private static final String ALGORITHM = "HmacSHA256";
    private static final ObjectMapper SORTED_MAPPER = new ObjectMapper();

    static {
        // This will sort the properties alphabetically
        SORTED_MAPPER.configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true);
        SORTED_MAPPER.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);  
        // This will remove the indention between keys and values in the requests
        SORTED_MAPPER.disable(SerializationFeature.INDENT_OUTPUT);
    }

    public boolean requestIsFromLithic(String apiKey, Object transaction, String requestHmacHeader) throws JsonProcessingException, NoSuchAlgorithmException, InvalidKeyException {
        String hmacSignature = hmacSignature(apiKey, toJsonStr(transaction));
        return hmacSignature.equals(requestHmacHeader);
    }

    private String hmacSignature(String key, String msg) throws NoSuchAlgorithmException, InvalidKeyException {
        SecretKeySpec secretKeySpec = new SecretKeySpec(key.getBytes(), ALGORITHM);
        Mac mac = Mac.getInstance(ALGORITHM);
        mac.init(secretKeySpec);
        byte[] bytes = mac.doFinal(msg.getBytes());
        return new String(Base64.getEncoder().encode(bytes));
    }

    private String toJsonStr(Object transaction) throws JsonProcessingException {
        return SORTED_MAPPER.writeValueAsString(transaction);
    }
}
using Newtonsoft.Json;
using System.Text;
using System.Security.Cryptography;
using Newtonsoft.Json.Linq;

public class HmacTester2
{
    public bool RequestIsFromLithic(String apiKey, string transaction, String requestHmacHeader)
    {
        var hmacSignature = HmacSignature(apiKey, ToJsonStr(transaction));
        return hmacSignature.Equals(requestHmacHeader);
    }

    private String HmacSignature(String key, String msg)
    {
        byte[] bytes = Encoding.UTF8.GetBytes(key);
        HMAC hmac = new HMACSHA256(bytes);
        bytes = Encoding.UTF8.GetBytes(msg);
        return Convert.ToBase64String(hmac.ComputeHash(bytes));
    }

    private String ToJsonStr(string transaction)
    {
        var jObj = (JObject)JsonConvert.DeserializeObject(transaction);
        Sort(jObj);
        return JsonConvert.SerializeObject(jObj);
    }

    private static void Sort(JObject jObj)
    {
        var props = jObj.Properties().ToList();
        foreach (var prop in props)
        {
            prop.Remove();
        }
        foreach (var prop in props.OrderBy(p => p.Name))
        {
            jObj.Add(prop);
            if (prop.Value is JObject)
            {
                Sort((JObject)prop.Value);
            }
            if (prop.Value is JArray)
            {
                foreach(var obj in prop.Value){
                    Sort((JObject)obj);
                }
            }
        }
    }
}
use base64;
use hmac::{Hmac, Mac};
use serde::Serialize;
use serde_json;
use sha2::Sha256;

fn sort_alphabetically<T: Serialize, S: serde::Serializer>(
    value: &T,
    serializer: S,
) -> Result<S::Ok, S::Error> {
    let value = serde_json::to_value(value).map_err(serde::ser::Error::custom)?;
    value.serialize(serializer)
}

#[derive(Serialize)]
struct SortAlphabetically<T: Serialize>(#[serde(serialize_with = "sort_alphabetically")] T);

#[derive(Serialize)]
struct Transaction {
    token: String,
    amount: i64,
}

fn main() {
    let args: Vec<String> = env::args().collect();

    let api_key = &args[1];
    let hmac_header = &args[2];

    type HmacSha256 = Hmac<Sha256>;

    let mut mac = HmacSha256::new_from_slice(api_key.as_bytes()).unwrap();

    let transaction = Transaction {
        token: "270a4a65-44d0-4fb2-9bf9-59fd860d6b94".to_string(),
        amount: 100,
    };

    let transaction_json = serde_json::to_string(&SortAlphabetically(&transaction)).unwrap();

    mac.update(&transaction_json.as_bytes());

    let code_bytes = base64::decode(hmac_header).unwrap();

    // `verify_slice` will return `Ok(())` if code is correct, `Err(MacError)` otherwise
    mac.verify_slice(&code_bytes[..]).unwrap()
}
function sort_multidimensional_associative_array($array) {
    $sorted_values = array();
    ksort($array);  // Sort the initial assoc array
    foreach($array as $key => $value) {
        if (is_array($value)) {
            $sorted_array_values = sort_multidimensional_associative_array($value);
            $sorted_values[$key] = $sorted_array_values;
        }
        else {
            $sorted_values[$key] = $value;
        }
    }
    return $sorted_values;
}

function to_json_str($json_object) {
    return json_encode($json_object);
}

function hmac_signature($key, $msg) {
    $hmac_buffer = hash_hmac('sha256', $msg, $key, true);
    return base64_encode($hmac_buffer);
}

function request_is_from_lithic($api_key, $transaction, $request_headers) {
    $sorted_transaction = sort_multidimensional_associative_array($transaction);
    $request_hmac = $request_headers["x-lithic-hmac"];
    $data_hmac = hmac_signature($api_key, to_json_str($sorted_transaction));

    return hash_equals($request_hmac, $data_hmac);
}

Note that the JSON the HMAC is generated from has no extra whitespace, uses all double quotes "" for strings, and sorts the key-value pairs in all JSON objects by key alphabetically.

Transaction webhooks may originate from changing IP addresses, and therefore should not be relied upon.

Schema

The request payload will contain a Transaction object.