Skip to main content
The capture adapter for Circle webhook v2 notifications — Wallets transactions and Contracts event logs. Verified deliveries become normalized, redacted Trace events. The source is external: Circle POSTs signed notifications to an HTTPS endpoint, at-least-once and unordered. This package hosts no server. It gives you the pieces — signature verification, envelope validation, deduplication, normalization — and one handler that composes them, so it embeds into whatever route already receives the POSTs. ESM only, Node ≥ 22, no runtime dependencies beyond @usehaia/trace-core. The runtime code uses only platform globals (Web Crypto, TextEncoder, atob), so it also runs on edge runtimes.

Install

Receive a delivery

createWebhookHandler runs the whole pipeline and returns the HTTP status your route should answer with:
write must never report success for an event it did not keep. Answering 200 for an unpersisted delivery stops Circle’s retries, which were the only way that event could still be saved. Return false or throw — the handler treats both as a persistence failure and answers 500. A writer that returns nothing at all is read as success, so a custom sink has to be explicit about refusal.
One handler serves any number of route paths. Circle requires a unique URL per subscription, but a delivery’s source is identified by notification_type, not by the URL it arrived on. One handler is also one capture session: it owns the seq counter, so everything it writes is one consistently ordered stream. Point the sink at the directory haia-trace build reads — .trace/events unless --dir says otherwise.

The HTTP decision

handle returns a discriminated HandleResult — the status, plus why: A 400/500 result carries a reason string for your logs. Verification runs before parsing: nothing about an unauthenticated body is trusted, not even that it is JSON. A record_failed result also un-records the notification_id, so Circle’s retry is processed as a fresh delivery instead of being acknowledged as a duplicate that never reached the sink.

What it records

Every event carries adapter: "trace-circle". Direction is part of a transaction’s name because a direction-less “transaction completed” cannot witness a milestone: money arriving from an escrow contract and money sent toward one are different facts. CONFIRMED and COMPLETE fold into one event because chains with instant finality can skip CONFIRMED. Anything else — an intermediate transaction state, a contract event outside the three above, an unknown notification type — is acknowledged with 200 and records nothing. Non-terminal states are delivery noise for a receipt; the terminal state arrives on its own.
The contract events are those of Circle’s arc-escrow sample and its RefundProtocol contract, which is what the shipped escrow-arc template reads. Pass your own normalize for a different vocabulary.
context_id is the escrow contract’s address, lowercased. A contract event takes it from contractAddress. A wallet transaction prefers its contractAddress too and otherwise uses the counterparty — the sourceAddress of an inbound, the destinationAddress of an outbound. A transaction with neither is recorded without a context_id rather than guessed into an operation. occurred_at is the fact’s own time, never the delivery time: a transaction’s updateDate, a contract event’s firstConfirmDate, falling back to the envelope timestamp and then to the recorder’s clock. Values are re-canonicalized to ISO-8601 UTC, the form the assembler’s ordering relies on. Payloads are allowlisted, never copied wholesale — a key Circle adds later is dropped by default. Every event carries notification_id and notification_type, plus:
  • a transaction: transaction_id, tx_hash, blockchain, state, wallet_id, source_address, destination_address, amounts, token_id, transaction_type, error_reason, contract_address;
  • a contract event: contract_address, blockchain, tx_hash, user_op_hash, event_signature, block_height.
The first delivery a handler accepts also writes one trace.attached attestation, so “no events” can never be mistaken for “the receiver was never wired up”. It attests the session, not an operation, so it carries no context_id and the assembler reports it as unassigned.

Options

The pieces

The handler composes these; use them directly if you need a different pipeline. createVerifier({ resolveKey })verify(rawBody, signature, keyId). Circle signs every v2 notification with ECDSA (P-256 / SHA-256) over the raw request body and sends X-Circle-Signature (base64 ASN.1 DER) and X-Circle-Key-Id; both header names are exported as SIGNATURE_HEADER and KEY_ID_HEADER. A malformed signature, key id or body is an invalid signature (false), not an error. Only a failing resolveKey throws — an unknown key id should return null. Imported keys are cached per key id. parseNotificationEnvelope(rawBody, source?) validates a raw body into a NotificationEnvelope (subscription_id, notification_id, notification_type, notification, timestamp, version), throwing a sourced Error on malformed JSON or a bad envelope. Only version: 2 is accepted — a v1 message has a different envelope and signature scheme. assertNotificationEnvelope does the same for an already-parsed value. createMemoryDedupeStore({ capacity }) implements DedupeStore (firstSeen, forget), keyed on notification_id, which Circle reuses across retries. The event sink is append-only and the assembler does not dedupe, so exactly-once has to be enforced before anything is written.
The default store holds 10 000 ids and is process-local. Two instances behind a load balancer, or one instance after a cold start, will each accept the same redelivered notification and the sink will hold it twice. A deployment that is not one long-lived process should back the two-method DedupeStore interface with shared storage.
normalizeNotification(envelope) is the default mapping, exported so you can wrap or replace it. The package also exports ADAPTER_ID and its types: WebhookHandler, WebhookHandlerOptions, HandleResult, HeaderBag, NormalizeNotification, NotificationVerifier, VerifierOptions, PublicKeyResolver, NotificationEnvelope, DedupeStore, MemoryDedupeOptions.

From deliveries to receipts

The events are the input to haia-trace build, which groups them by context_id and assembles one Operation Receipt per escrow contract:
escrow-arc reads two witnesses. Circle’s events close the money half — the PaymentCreated deposit, the withdrawal or refund disposition — and a failed wallet transaction surfaces as a fault. The obligation half (agreement, delivery, criteria) has no webhook witness, and agreement is one of the template’s required stages, so a webhook-only run assembles as partial until your own service records its escrow.* events into the same run directory.