Building a Travel Rule Integration
How to wire Contacts, Channels, Payment Links, and Travel Rule RFIs together for a clean, compliant integration.
The model
A complete Travel Rule integration on BEEM exercises three groups of endpoints on BEEM's API. Your application drives all three; BEEM raises an RFI when more information is needed on a specific payment.
Three things your integration does:
Manage Contacts. Create and update the Companies and Individuals you transact with via the Contacts endpoints, and attach their crypto addresses as PaymentInstruments. Contacts are the single source of truth for counterparty data, so updates flow through to every future payment automatically.
Attach complianceDetails on every create. On every POST /api/v2/channel and POST /api/v1/pay/summary, include a complianceDetails object. Pass contactId referencing a saved Contact (recommended), or pass partyDetails[] inline if the counterparty isn't saved.
Resolve RFIs as they arrive. When BEEM needs additional information on a payment — most commonly an inbound Channel deposit from a sender you haven't seen before — a Travel Rule RFI is raised and the payment is held. Detection runs through the existing transaction-held webhooks; resolution runs through the Travel Rule RFI endpoints. See Resolving RFIs in detail below.
Two paths through the same model:
Forward flow (purple) — the happy path for counterparties you already know. Manage Contacts, include complianceDetails.contactId on every payment, and payments process without further intervention.
Recovery flow (teal) — the RFI loop for payments where BEEM doesn't have everything it needs at create time. Receive the transaction-held event, confirm it's a Travel Rule hold by checking the RFI list, assign a Contact, the payment resumes.
The internal references between the three endpoint groups — complianceDetails.contactId resolving to a saved Contact, Channels and Payment Links raising RFIs when needed, the Travel Rule RFI endpoints unblocking held payments — are handled by BEEM. You only ever call the endpoints; the linking between them is automatic.
Two adoption paths
You can satisfy complianceDetails two ways. Pick one and stay consistent.
Path A — Contacts-first (recommended) Maintain a Contacts store for every counterparty you transact with. On each payment, pass complianceDetails.contactId referencing the saved Contact.
Why it scales. Counterparty data lives in one place. Edits propagate to every future payment automatically. RFIs are resolved by linking the same Contact via assign-contact. Reconciliation, KYC reviews, and ops queues all key off the same record.
Path B — Inline party details Pass complianceDetails.partyDetails[] directly on every payment. No Contact is created.
Why you might use it. For one-off transactions to counterparties you'll never see again. For any pattern where the same counterparty appears more than once, Path A is cleaner.
The rest of this guide assumes Path A. The API supports Path B identically if you'd rather inline.
Scenarios
1. Outbound Payment Link to a known counterparty
You're sending crypto to a recipient you already have a Contact for. Typical uses: supplier payouts, refunds, treasury transfers.
1. (one-time) Create / fetch Contact for the recipient
POST /platform/v1/contacts
POST /platform/v1/contacts/{id}/instruments ← attach their crypto address
2. Create the outbound Payment Link
POST /api/v1/pay/summary
{
"type": "OUT",
"currency": "USDC",
"amount": 100,
"complianceDetails": { "contactId": "<contact-uuid>" }
}
3. Accept / submit
4. Listen for status-change → COMPLETENo RFI is raised — counterparty data is complete at create time. The payment processes through the normal Payment Link Out lifecycle.
2. Inbound Payment Link from a known customer
You're requesting a deposit from a specific customer you've onboarded. Typical uses: invoices, checkout for an identified account.
1. (one-time) Create / fetch Contact for the customer
POST /platform/v1/contacts
2. Create the inbound Payment Link
POST /api/v1/pay/summary
{
"type": "IN",
"currency": "USDC",
"amount": 100,
"complianceDetails": { "contactId": "<contact-uuid>" }
}
3. Share redirectUrl with the customer
4. Listen for status-change → COMPLETENo RFI is raised — the originator (customer) is identified up front.
3. Inbound Channel deposit from a new sender
A static Channel receives a deposit from a sender BEEM doesn't yet have Contact details for. This is the most common RFI scenario.
1. Customer sends crypto to your Channel address
2. BEEM detects the deposit, raises a Travel Rule RFI, holds the payment
3. transaction-held webhook fires on the payment:
- Channel deposit → layer1:payment:channel:transaction-held
- (the same family of events is used for KYT holds — see step 4)
4. Your handler:
- GET /platform/v1/travel-rule-rfis ← confirm it's a Travel Rule hold,
not a KYT hold
- If an RFI is returned for this payment:
Identify (or create) the Contact representing the sender
PATCH /platform/v1/travel-rule-rfis/{externalId}/assign-contact
{ "contactId": "<contact-uuid>" }
- If no RFI is returned:
Route to your KYT / manual-review workflow instead
5. BEEM validates the Contact, resolves the RFI
6. The held payment finalises and the wallet creditsIf automation can't identify the right Contact (e.g. a brand-new sender), the RFI surfaces in the Portal's Missing Contact page for your operations team to resolve manually.
Resolving RFIs in detail
An RFI represents "BEEM needs more information about this payment before it can settle." Detection runs through the existing hold events; resolution runs through the Travel Rule RFI endpoints.
Step 1 — Detect via the transaction-held webhooks
transaction-held webhooksBEEM does not emit a dedicated "RFI raised" event. The signal that an RFI may exist is the standard transaction-held event firing on the held payment:
| Payment type | Event code |
|---|---|
| Channel deposit | layer1:payment:channel:transaction-held |
| Payment Link (In or Out) | layer1:payment:checkout:transaction-held |
Both events deliver the standard envelope. The payload structure is unchanged from its existing meaning — there is no Travel Rule flag on the event itself.
Important — these events are shared with KYTA
transaction-helddelivery may indicate either an open Travel Rule RFI or a KYT (Chainalysis) screening flag. The payload does not say which. You must use Step 2 to determine the hold reason before reacting.
Step 2 — Confirm by listing RFIs
On every transaction-held delivery, call:
| Endpoint | Purpose |
|---|---|
GET /platform/v1/travel-rule-rfis | List open RFIs on your account. Each RFI carries the identifier of the payment it relates to and indicates which counterparty information is needed. |
If an open RFI exists for the held payment, the hold is a Travel Rule matter — proceed to Step 3. If no RFI is returned, the hold is being driven by KYT or another compliance process — route it to your existing remediation workflow and do not call assign-contact.
Step 3 — Resolve via assign-contact
assign-contact| Endpoint | Purpose |
|---|---|
PATCH /platform/v1/travel-rule-rfis/{externalId}/assign-contact | Submit contact details to satisfy the RFI. BEEM validates the data and resolves the RFI. |
Three ways to satisfy the PATCH endpoint
The assign-contact endpoint accepts three request body shapes — pick whichever matches your situation.
1. Existing Contact — assign by ID (recommended)
You've already saved this counterparty as a Contact. Reference it by ID:
{
"contactId": "123e4567-e89b-12d3-a456-426614174000"
}2. Inline Individual — create-and-assign in one call
No saved Contact exists yet. Pass full individual details and BEEM creates the Contact for you while resolving the RFI:
{
"type": "INDIVIDUAL",
"firstName": "Jane",
"middleName": "A.",
"lastName": "Doe",
"dateOfBirth": "31-01-1990",
"relationshipType": "THIRD_PARTY",
"address": {
"addressLine1": "123 Main St",
"city": "London",
"postCode": "SW1A 1AA",
"country": "GB"
}
}3. Inline Company — create-and-assign in one call
Same idea for companies:
{
"type": "COMPANY",
"legalName": "Acme Pty Ltd",
"registrationNumber": "2021/123456/07",
"relationshipType": "THIRD_PARTY",
"address": {
"addressLine1": "456 Commerce Blvd",
"city": "Cape Town",
"postCode": "8001",
"country": "ZA"
}
}
Which path to use?If the counterparty is one you'll see again, prefer option 1. Maintaining Contacts keeps counterparty data in one place and reuses it across payments. Use options 2 or 3 for one-off counterparties where you don't want a permanent Contact, or where you want to satisfy an RFI without a separate Contact-creation step.
Practical notes
Subscribe rather than poll. Add layer1:payment:channel:transaction-held and layer1:payment:checkout:transaction-held to your Hook so your handler runs on delivery instead of on a schedule. See Events for the full event catalogue.
Always disambiguate before reacting. Because transaction-held covers both Travel Rule and KYT, treat the event as "something needs checking" rather than "Travel Rule RFI". The GET /platform/v1/travel-rule-rfis call in Step 2 is what tells you which.
Be idempotent. Webhook deliveries can be retried. Use the payment's identifier (from the event) and the RFI's externalId (from Step 2) to de-duplicate so the same RFI isn't processed twice. See Building a Webhook Listener for the standard pattern.
Fall back to manual. Programmatic resolution covers the common case (you recognise the sender). For everything else — unknown sender, suspicious activity, manual KYC — surface the RFI to your ops team via the Portal's Missing Contact page rather than guessing.
Recommended system design
A clean implementation typically looks like this:
| Layer | Responsibility |
|---|---|
| Customer onboarding | When a customer signs up or is added to your CRM, create a corresponding BEEM Contact via POST /platform/v1/contacts. Attach their crypto addresses as PaymentInstruments via POST /platform/v1/contacts/{id}/instruments. |
| Payment creation | When creating any Channel, Payment Link In, or Payment Link Out, look up the relevant Contact by your external ID, and pass complianceDetails.contactId on the create call. |
| Hold-event handler | A background worker that listens for transaction-held webhook deliveries on both the Channel and Payment Link surfaces. On every delivery, call GET /platform/v1/travel-rule-rfis to determine whether the hold is a Travel Rule matter. If yes, resolve via assign-contact; if no, route to the KYT / manual-review workflow. |
| Ops console | Either mirror the Portal's Missing Contact page in your own ops tooling, or train ops to resolve from the Portal directly. |
Keep Contacts canonical. Update Contact data in BEEM whenever your source data changes (KYC refresh, new address, address tag updates). This is what makes Path A scale.
Don't shortcut the RFI loop. Even with complianceDetails populated on every create call, RFIs will fire for legitimate reasons (new senders, completeness checks). An integration that ignores transaction-held events will eventually hold payments unnecessarily.
Sandbox testing
The integration is only as solid as its RFI loop. A good sandbox test plan:
Happy path — outbound. Create a Contact, create a Payment Link Out with complianceDetails.contactId, confirm it processes without a transaction-held event.
Happy path — inbound link. Same with a Payment Link In.
RFI path — Channel deposit. Create a Channel, send a sandbox deposit from a sender you haven't added as a Contact. Confirm the layer1:payment:channel:transaction-held event fires, your handler calls GET /platform/v1/travel-rule-rfis, finds the matching RFI, resolves it via assign-contact, and the payment settles.
Disambiguation. Trigger a KYT-style hold in sandbox (if your sandbox supports it). Confirm the same transaction-held event fires, your handler queries the RFI list, finds no matching RFI, and routes the event to your KYT path instead of attempting assign-contact.
Manual fallback. Drive an RFI you intentionally don't resolve programmatically. Confirm it surfaces on the Portal's Missing Contact page, resolve it there, and confirm the payment settles afterwards.
See Testing your Integration for sandbox wallets, faucets, and test assets.
What's next
Travel Rule — Concept overview — what the Travel Rule is and why it applies.
Travel Rule — Post 1 July 2026 — The enforcement behaviour this guide is designed to satisfy.
Contacts — The Contacts data model — Companies, Individuals, addresses, verification.
Building a Webhook Listener — Wire up your endpoint to receive transaction-held and other event webhooks.

