Gareth Wilson Gareth Wilson

Guide to Uphold Webhooks: Features and Best Practices

Published


Uphold webhooks notify your platform when something changes for your users on Uphold's Enterprise APIs: a user is created, a KYC or KYB check changes status, an account balance moves, an external account is linked, or a transaction is created or completed. If you build a product on Uphold's Core API, webhooks keep your records in step with Uphold without polling.

This guide covers the Uphold Enterprise REST API webhooks: how they are delivered and signed, the event shape and event keys, retries and redelivery, and the best practices for running an Uphold receiver in production. Uphold's older consumer API had a different webhook scheme, which is not covered here.

What are Uphold webhooks?

Uphold webhooks are delivered by Svix under Uphold's name. You subscribe to event types in the Svix-hosted Webhooks Portal, and each event arrives as a JSON POST with three headers: Webhook-Id, Webhook-Timestamp and Webhook-Signature.

Uphold uses the white-labelled Webhook- prefix rather than Svix's default svix-. The scheme behind the headers is Svix's, and Uphold's docs send you to Svix's verification guide for the details.

Every event has the same four top-level fields: id, type, createdAt and data.

Uphold webhook features

FeatureDetails
Delivery platformSvix, with Webhook-* headers
ConfigurationWebhooks Portal, from the Enterprise Portal or the Request webhook management link API
Event shape{id, type, createdAt, data}
Event fieldBody type, for example core.transaction.status-changed
HeadersWebhook-Id, Webhook-Timestamp, Webhook-Signature
SchemeBase64 HMAC-SHA256 over {Webhook-Id}.{Webhook-Timestamp}.{raw body}, per Svix
SecretA signing key unique to each receiving endpoint
RetriesExponential backoff for up to 24 hours on a non-2xx response or timeout
Manual redeliveryEnterprise Portal, Webhooks section
Source IPsSvix's published list

Common events

Each group of Core API endpoints has its own events, and the KYC Connector API adds a few more. The keys follow the pattern <api>.<resource>.<action>:

AreaEvent keys
Userscore.user.created, core.user.deleted
KYCcore.kyc.<check>.status-changed for profile, email, phone, address, identity, proof-of-address, tax-details, customer-due-diligence, enhanced-due-diligence, crypto-risk-assessment, self-categorization-statement, screening and risk
KYBcore.kyb.<check>.status-changed for onboarding, profile, associated-persons, documents, financial-institution and attestation, plus core.kyb.associated-person.created, .deleted and three per-person status events
Assetscore.assets.cooldown-ended
Accountscore.account.created, core.account.balance-changed, core.account.archived, core.account.unarchived
External accountscore.external-account.created, .status-changed, .deleted
Transactionscore.transaction.created, core.transaction.status-changed
KYC Connectorkyc-connector.sumsub.ingestion.created, .status-changed, and the same two for veriff

Here is the core.transaction.status-changed example from Uphold's API spec, trimmed:

{
  "id": "123e4567-e89b-12d3-a456-426614174000",
  "type": "core.transaction.status-changed",
  "createdAt": "2021-01-01T00:00:00.000Z",
  "data": {
    "transaction": {
      "id": "623000c8-9bdf-4a2b-aa3d-6a6b44a7f6a0",
      "origin": {
        "amount": "100.00",
        "asset": "GBP",
        "node": { "type": "account", "id": "b98e4f0d-a67d-4126-a99d-666f7e0315d9" }
      },
      "destination": {
        "amount": "0.0021",
        "asset": "BTC",
        "node": { "type": "account", "id": "555a99a1-620d-4431-a0ac-b43e1aad2bdc" }
      },
      "status": "completed",
      "createdAt": "2024-07-24T15:22:39.000Z",
      "updatedAt": "2024-07-24T15:32:39.000Z"
    }
  }
}

data holds the object the event is about, such as data.transaction, data.user or data.account. Amounts are decimal strings, so parse them with a decimal type rather than a float.

Two of Uphold's own examples don't match the subscription keys. The overview's envelope example shows "type": "core.users.created", while the key you subscribe to is core.user.created. The spec's only example for core.account.balance-changed shows "type": "core.account.balance.changed". Until you have seen real deliveries, match those two leniently.

Setting up Uphold webhooks

  1. Open the Webhooks Portal, either from the Webhooks section of the Enterprise Portal or by calling the Request webhook management link endpoint, which returns a one-time login link.
  2. Add an endpoint with your HTTPS URL and choose the event types to send to it.
  3. Copy the endpoint's signing secret and store it server-side, for example as UPHOLD_WEBHOOK_SECRET. Svix-issued secrets start with whsec_; keep the prefix.
  4. Use the portal's testing tools to send an example event, and its delivery logs to confirm the result.

If your endpoint is behind a firewall, allow Svix's published source IP addresses.

Securing Uphold webhooks

Uphold doesn't restate the algorithm and refers you to Svix's verification guide. Following that guide: concatenate Webhook-Id, Webhook-Timestamp and the raw body with ., compute an HMAC-SHA256 using the base64-decoded part of the secret after whsec_, base64-encode the result, and compare it with each v1, entry in Webhook-Signature.

const crypto = require('crypto');
const express = require('express');

function verifyUpholdSignature(rawBody, headers, secret) {
  const msg_id = headers['webhook-id'];
  const msg_timestamp = headers['webhook-timestamp'];
  const signature_header = headers['webhook-signature'];
  if (!msg_id || !msg_timestamp || !signature_header) return false;
  if (typeof secret !== 'string' || !secret.startsWith('whsec_')) return false;

  // The HMAC key is the base64-decoded part of the secret after "whsec_".
  const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
  const signed_content = Buffer.concat([Buffer.from(`${msg_id}.${msg_timestamp}.`, 'utf8'), rawBody]);
  const expected = Buffer.from(
    crypto.createHmac('sha256', key).update(signed_content).digest('base64'),
    'utf8'
  );

  // The header can hold several space-separated "v1,<signature>" entries.
  return signature_header.split(' ').some((entry) => {
    const [version, signature] = entry.split(',');
    if (version !== 'v1' || !signature) return false;
    const received = Buffer.from(signature, 'utf8');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

const app = express();

app.post('/webhooks/uphold', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.UPHOLD_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyUpholdSignature(req.body, req.headers, secret)) {
    return res.sendStatus(400);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.id, queue the event, then respond.
  res.sendStatus(200);
});

The same check in Python:

import base64
import hashlib
import hmac
import os


def verify_uphold_signature(raw_body: bytes, headers, secret: str) -> bool:
    msg_id = headers.get("webhook-id")
    msg_timestamp = headers.get("webhook-timestamp")
    signature_header = headers.get("webhook-signature")
    if not (msg_id and msg_timestamp and signature_header):
        return False
    if not secret or not secret.startswith("whsec_"):
        return False

    key = base64.b64decode(secret[len("whsec_"):])
    signed_content = f"{msg_id}.{msg_timestamp}.".encode("utf-8") + raw_body
    expected = base64.b64encode(hmac.new(key, signed_content, hashlib.sha256).digest())

    for entry in signature_header.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature.encode("utf-8"), expected):
            return True
    return False


# In FastAPI: raw_body = await request.body()
# verify_uphold_signature(raw_body, request.headers, os.environ["UPHOLD_WEBHOOK_SECRET"])

The Svix SDKs and the Standard Webhooks libraries read the webhook-* header names directly, if you prefer a library. Webhook-Timestamp is signed, so your receiver can reject old deliveries, but Uphold states no tolerance.

Make Uphold webhooks production-ready. Hookdeck Event Gateway verifies Uphold's Webhook-Signature, queues every user, KYC and transaction event, and keeps it available for replay beyond Uphold's 24-hour retry window.

Hookdeck's Uphold source type verifies the webhook-* headers with the secret you enter, keeping its whsec_ prefix. Hookdeck does not apply a timestamp window, so a delayed retry still verifies.

Uphold webhook limitations and pain points

Delivery IDs and event IDs are different things

The Problem: Deduplication misses repeats, or treats different events as the same one.

Why It Happens: Webhook-Id and Webhook-Timestamp are Svix's per-delivery values, while the body's id and createdAt come from Uphold's platform. Uphold's docs warn against confusing them. Webhook-Id stays the same when a webhook is resent, but Webhook-Timestamp is the time of sending.

Workarounds:

  • Deduplicate on the body's id (or on Webhook-Id, which is stable across resends), never on Webhook-Timestamp.
  • Use createdAt for event time and ordering.

How Hookdeck Can Help: Hookdeck's deduplication can key on the body's id, so retried deliveries are dropped before they reach your service.

Example payloads that don't match the keys

The Problem: A handler built from the docs never matches account balance events.

Why It Happens: Two of Uphold's published examples spell type differently from the subscription key (core.users.created, core.account.balance.changed).

Workarounds:

  • Match those event types on both spellings until you have seen real deliveries.
  • Log any unrecognised type with its body so mismatches show up quickly.

How Hookdeck Can Help: Hookdeck keeps every request with its body and headers, so you can check the exact type strings Uphold sends and set up filters that match them.

A 24-hour retry window

The Problem: An outage that lasts more than a day loses events.

Why It Happens: Uphold retries failed deliveries with exponential backoff for up to 24 hours, then stops.

Workarounds:

  • Use manual redelivery in the Enterprise Portal for anything that failed.
  • Alert on failed deliveries in the portal's logs.

How Hookdeck Can Help: Hookdeck accepts Uphold's deliveries while your service is down and keeps them for replay, so recovery doesn't depend on Uphold's retry window.

Regulated data in transit

The Problem: KYC and KYB events tell you about identity checks, and you need to be sure they came from Uphold.

Why It Happens: Anyone can post to a public URL. The only proof of origin is the signature, plus Svix's IP ranges if you restrict by network.

Workarounds:

  • Verify every request, fail closed when the secret is missing, and treat the event as a prompt to read the current KYC status from the Core API before granting access.

How Hookdeck Can Help: Hookdeck verifies each signature and stops unsigned or badly signed requests before they reach your service, so only verified events reach the services that handle user data.

Best practices

Verify against the raw body

Use express.raw() in Express, Buffer.from(await request.arrayBuffer()) in a Next.js route handler, or await request.body() in FastAPI, and parse only after verification.

Route on type

Switch on the body's type and log anything you don't recognise. Uphold's spec examples show the KYC and KYB status events carrying the new status and the user ID, so you can usually act without another API call.

Acknowledge fast and process asynchronously

Uphold counts a timeout as a failure. Return a 2xx as soon as the event is stored. See why to process webhooks asynchronously.

Make handlers idempotent

Retries and manual redelivery both resend events. Store the event id before processing. See our guide to webhook idempotency.

Use decimals for amounts

Transaction and balance amounts are strings such as "0.0021". Keep them as decimals end to end.

Conclusion

Uphold's Enterprise API webhooks arrive through Svix with Webhook-Id, Webhook-Timestamp and Webhook-Signature headers and a consistent {id, type, createdAt, data} body. Verify each one against the raw body with the endpoint's whsec_ secret, route on type, deduplicate on the event id, and plan for the 24-hour retry limit.

Hookdeck Event Gateway sits in front of your receiver, verifies Uphold's signatures, and holds each event until your service has processed it.

Get started with Hookdeck for free and handle Uphold webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.