Overview
Instead of constantly polling our API for updates, webhooks deliver event notifications instantly when something happens:- Transaction status changes
- Customer onboarding updates
- Account changes
- System events
Webhook Signature Verification
Dakota signs every webhook with an Ed25519 signature (not HMAC). To verify a delivery, you need Dakota’s public key — set it in your environment asDAKOTA_WEBHOOK_PUBLIC_KEY.
Dakota Public Keys
Each key is 64 hex characters (32 raw bytes).
Signature Headers
Dakota includes these headers in every webhook request:
Full verification implementations for Node.js, Python, Go, Rust, and Java are in Verification Code Examples below.
Setting Up Webhooks
1. Create a Webhook Endpoint
Create an endpoint in your application to receive webhook notifications:2. Register Your Webhook
Register your endpoint with Dakota Platform:Webhook Target Fields
event_types is an exact-match delivery filter. With global: false, Dakota delivers only the event types you list. An event whose type is absent from the list is never delivered and never retried — it is not queued for your target at all, so there is nothing to redeliver later. List every type you consume, and add a new type to the list before you rely on it. Set global: true to receive every event type for your client; event_types is then ignored.
cURL
Webhook Events
The envelope schema defines
data.previous_attributes and a top-level metadata object, and the API reference lists both as optional. In practice no event populates either today — they are absent from every delivered payload. Drive your state machine off data.object alone; do not write change-detection logic that depends on previous_attributes being present.Transaction Events
Transaction lifecycle events fire for auto-account (onramp/offramp/swap) and one-off transactions. There is no separatefailed event — failure is reflected in the status field of the *.updated event.
Status progression
Thedata.object.status field on transaction webhooks progresses through the following observable values. Inspect this field on every *.updated event to drive your own state machine — there is no separate “status rank” or priority concept beyond this order.
A transaction moves forward through this sequence — statuses do not regress.
failed is final. completed is final for a successful forward payment, but a completed transaction can later move to pending_return, returned, pending_reversal or reversed (for example when the receiving bank returns an ACH or wire), and each change arrives as another *.updated event. Keep processing *.updated events after completed and read data.object.status each time. See Transaction Status Reference for the full list. Other internal statuses (for example, awaiting_confirmation, broadcasted) may appear on the underlying transaction object but are not surfaced as distinct webhook lifecycle stages.
Fee fields in the receipt
Thedata.object.receipt object breaks the transaction’s fees into separate fields. GET /transactions/{transaction_id} returns the same fee fields, but in a different shape: on the webhook each fee is a flat decimal string ("developer_fee": "1.25"), while the REST receipt returns each fee as an object ("developer_fee": {"amount": "1.25", "asset": "USDC"}).
The rate you set is returned:
GET /accounts/{account_id} and one-off transaction reads include developer_fee_bps, which is 0 when no percentage fee is set. One-off transactions created before the field was introduced also report 0, whatever rate they were created with. A fixed fee is returned as developer_fee_fixed, and fixed-fee accounts also return minimum_deposit; both are absent otherwise. Changing the fee with PATCH /accounts/{account_id} applies to future transactions only. Check the fee actually charged via developer_fee on each receipt. See Developer Fees.auto_account.* events carry the account’s rate too: developer_fee_bps always, and developer_fee_fixed only when a fixed fee is set. On the event, developer_fee_fixed is a decimal string with trailing zeros trimmed, so a fee set as "10.00" arrives as "10" and "10.50" as "10.5". Compare it as a number, not as a string.amount — amount-like fields live inside data.object.receipt.
data.object.status to distinguish processing, completed, failed).
status stays processing, and hold_reason says why they are not moving:
hold_reasonis on everytransaction.auto.createdandtransaction.auto.updatedevent, and onGET /auto-transactions. It isnullwhen the transaction is not held.proof_of_address_required: collect the customer’s proof of address. When a valid one is approved, you receivetransaction.auto.updatedwithhold_reason: nullfor each of these deposits. The customer is alsofrozenwithreason_code: pending_proof_of_addressuntil then (seecustomer.kyb_status.updated).- A transaction can also be held for other checks. Those keep
hold_reason: null. hold_reasonis an open set: ignore values you do not recognise.
POST /transactions. Covers both off-ramp (crypto → fiat with payment_reference support) and swap (crypto → crypto with a destination network override). The one-off artifact is discarded after the transaction completes.
data.object carries a one-off transaction snapshot — the abbreviated { id, customer_id, status } object shown in some list examples is just an example view, not a smaller payload. It is close to the response body of GET /transactions/{transaction_id} but not field-for-field identical — several keys are spelled differently on the event:
destination_amount on the event is the amount you requested (0 if you omitted it) and has no counterpart on the REST read. Several REST fields are not emitted on the event at all, including resource_type, destination_payment_rail, and developer_fee_bps; read developer_fee_bps from GET /transactions/{transaction_id}. Fields that carry no value are omitted rather than sent as null. The example below illustrates the shape; log a delivered payload and code against what you actually receive.
OneOffTransaction schema on GET /transactions/{transaction_id} and the One-off transaction lifecycle.
Wallet Transaction Events
A transaction sent from a non-custodial wallet (POST /wallets/{wallet_id}/transactions) fires wallet.transaction.created when it is submitted and wallet.transaction.updated each time its status or details change.
Each event carries the status twice:
data.object.status_codeis the machine-readable status. Branch on it.data.object.statusis a display label for the same status, such asWaiting For Signature,Broadcasted,SuccessorFailed. Show it to people, but don’t match on it.
completed, failed and reversed don’t change afterwards; another wallet.transaction.updated with the same status_code can still arrive, for example to add the hash. A failed transaction is not proof that no funds moved: it can also mean the provider couldn’t tell whether the send went out, with or without a hash. Before you send the payment again, check the chain: look up the hash if there is one, otherwise check the wallet’s activity on chain, or ask Dakota. New codes may be added, so treat a code you don’t recognise like unknown, which means the status could not be determined: don’t act on it as a failure. The Transaction Status Reference defines every code.
The event’s hash is transaction_hash on the wallet transaction returned by the API.
wallet.transaction.updated
wallet.transaction.created has the same data.object, with status_code waiting_for_signature.
Deposit Events
Dakota holds a deposit that it can’t process. For example, a deposit smaller than the account’s minimum deposit is held, and so is a deposit to an account that has used itsmax_transactions cap. Dakota keeps the funds and creates no transaction, so no transaction.* event reports a held deposit. These two events do:
deposit.held: a deposit was held.deposit.returned: a held deposit’s funds are back with the sender.
deposit_id names the deposit on every event about it.
deposit.held — queued each time a deposit enters held: when it’s first held, when a return fails, and when a manual resolution is undone. The same deposit_id can get it more than once, so upsert on deposit_id.
reason is one of:
New values may be added. Treat an unknown
reason as a held deposit, not as an error.
deposit.returned — fires each time the deposit enters returned, which is normally once: when the funds are back with the sender.
deposit.returned carries deposit_id, account_id, customer_id, amount, asset, network_id and transaction_hash as on deposit.held, plus:
Handling deposit events
deposit.heldis queued each time a deposit enters held: when it’s first held, when a return fails, and when a manual resolution is undone. The samedeposit_idcan get it more than once.held_atis the time of that hold. Upsert ondeposit_id.- Every deposit that gets
deposit.returnedalso gets adeposit.held, queued no later than thedeposit.returned. If Dakota only learns of the deposit once its return is already in flight or done, it sendsdeposit.heldthen, withheld_atset to when the deposit was originally held. deposit.returnedfires each time the deposit enters returned, which is normally once: when the funds are back with the sender. If a returned deposit is moved back to held and returned again, you getdeposit.heldand then a seconddeposit.returned.- A deposit that Dakota support resolves by hand gets no event for the resolution.
- Webhook delivery order isn’t guaranteed, so order a deposit’s events by
held_atandreturned_at, not by arrival. The event with the later timestamp is the latest state Dakota reported, but a resolution by support sends no event, and the timestamps are whole seconds, so two can be equal. deposit_id,account_id,customer_id,amount,assetandreasonnever change across a deposit’s events. Payload fields describe the deposit as Dakota stores it.- Deduplicate deliveries on the envelope
id(also sent asX-Dakota-Event-ID), which stays the same across retries of one event. Twodeposit.heldevents for the same deposit are separate events with differentids, so deduplicating onidkeeps both; upserting ondeposit_idcollapses them into one record.
Account Verification Events
An external institution, such as a bank, brokerage or PayPal, can verify that your customer owns their Dakota account number by sending one or two small ACH credits (each under $1.00) and pulling the total back a few days later. Your end user then enters the amounts with that institution to finish linking the account. These micro-deposits never appear as transactions, so notransaction.* event reports them. These two events do:
account_verification.received: a micro-deposit posted to the customer’s account.account_verification.returned: the institution pulled some or all of a micro-deposit back.
amount to your end user so they can complete verification. Both events carry the same fields, and id names the micro-deposit on every event about it.
A webhook target receives these events only if it is
global or lists both event types in event_types. A target with global: true receives every event type, including ones added later. See Webhook Target Fields.id and amount.
status is one of:
account_verification.returned — fires for each micro-deposit a return applies to. The institution usually pulls back the total of its credits in one debit, which Dakota applies to that institution’s outstanding micro-deposits, oldest first, so a two-credit verification normally ends with two
returned events. It also fires if the credit itself is sent back to the institution.
account_verification.returned carries the same fields as account_verification.received, with status, returned_amount and, once the full amount is back, returned_at updated.
Handling account verification events
- Show
amountfromaccount_verification.receivedto your end user. By the timeaccount_verification.returnedarrives, the institution has usually finished its check; the amounts stay the same on both events. - Upsert on
id.customer_id,amount,asset,counterparty_nameandreceived_atnever change across a micro-deposit’s events. - A micro-deposit can get more than one
account_verification.returnedif the institution pulls it back in pieces: first withstatuspartially_returned, thenreturned. Use the event with the higherreturned_amountas the latest state. - Webhook delivery order isn’t guaranteed, so an
account_verification.returnedcan arrive before theaccount_verification.receivedfor the sameid. Treat whichever arrives first as creating the record. - Deduplicate deliveries on the envelope
id(also sent asX-Dakota-Event-ID), which stays the same across retries of one event. - To recover missed events or look up past micro-deposits, call List account verification micro-deposits with the
customer_id. It returns the same fields, newest first.
Customer Events
customer.kyb_status.created — emitted once per verification step, when Dakota first records a KYB status for that step. A normal approval therefore sends twocreated events for the same customer, with identical payloads but different envelope ids. Deduplicate deliveries on the envelope id, as described above, and make your handler safe to run twice for the same customer. The payload does not say which step an event describes. kyb_status carries whatever status Dakota records first: approved for a customer that provisioning approves, or auto_declined for an application that is auto-declined. Every later change to that status arrives as customer.kyb_status.updated. A created event can also fire again for a step Dakota previously removed and then records anew, so do not assume one created per customer.
The kyb_status in these events is the status of one verification step (not_started, in_review, approved, rejected, requires_info, auto_declined, frozen). It is not the customer-level kyb_status that GET /customers/{customer_id} returns (active, pending, partner_review, rejected, frozen), so approved in an event does not mean the customer reads active. Read the customer for the customer-level value.
customer.kyb_status.created as well as customer.kyb_status.updated. A target that lists only customer.kyb_status.updated never learns the customer’s first KYB status, because event_types is an exact-match filter. Like customer.kyb_status.updated, it carries reason_code approved_with_poa_limit when it approves an individual without a valid proof of address (see below).
customer.kyb_status.updated — emitted when the status of a verification step changes. As with created, kyb_status is the status of that one step, not the customer-level value, so approved here does not mean the customer reads active; read the customer for that.
customer.kyb_status.updated events carry an additional reason_code that explains why the status changed. It is present only on transitions that have a defined reason; on all other status updates it is absent.
A compliance freeze carries no
reason_code: Dakota does not disclose why a customer is frozen for a compliance reason. Only a proof of address freeze shows its reason, so you know which document to collect.
For a customer who is already approved:
- When a compliance freeze starts, you receive
customer.kyb_status.updatedwithkyb_statusfrozen. - When the freeze is lifted, you receive
customer.kyb_status.updatedwith the customer’s status:approved(withreason_codeapproved_with_poa_limitwhen that applies), orin_reviewwhen the customer has no status to restore.
- No
frozenevent is sent. The customer readskyb_statuspendingonGET /customers/{customer_id}until compliance decides. - If the application is declined, you receive its
rejectedevent as usual. - If the application is approved while the freeze stands, its
approvedevent (withreason_codeapproved_with_poa_limitwhen that applies) is held back and sent when the freeze is lifted. Until then the customer still readspending. - If the freeze is lifted before a decision, no event is sent.
kyb_status.
Example payload with reason_code:
external_id is present only when you set one for the customer.
customer.updated — defined and subscribable, but no Dakota flow currently emits it. Do not build on it. The customer changes Dakota does deliver today are KYB status (customer.kyb_status.created / customer.kyb_status.updated), capability standing (customer.capability_status.updated), and deletion (customer.deleted, below). Changes to a customer’s name or email — which Dakota may apply itself during KYB provisioning — are not surfaced on any webhook; re-read GET /customers/{customer_id} if you need them.
customer.deleted
customer.deleted is delivered to targets subscribed to all events (global: true), but it is not yet in the public EventType enum, so it cannot be named in an event_types filter today.
Token-sharing imports carry Notes:
import_reference. For a customer created via a token-sharing import (Persona, Sumsub), the customer.created payload includes data.object.import_reference, a { source, reference } object where reference is the token you submitted for that import (for Persona, the cnst_... share token). Use it to tie the event back to the source record you minted the token from, with no extra lookup:sourcetells you which import produced the customer (persona,sumsub), so you branch on one field rather than a per-vendor key.- We cannot include your source Inquiry id itself: Persona does not share it with Dakota as the destination organization. The token you submitted is the reliable correlation key.
import_referenceis a correlation hint, not a primary key: in rare import-retry cases the samereferencecan appear on more than onecustomer.created. If you would rather not track tokens at all, the import job maps every token to its result:GET /customers/persona-import-jobs/{job_id}returns each token alongside itscustomer_idandapplication_id.
import_reference on token-sharing imports, the payload carries no other provider-side metadata (no Persona Inquiry IDs, no Sumsub applicant IDs). To resolve a Dakota customer back to a provider identifier, either:
- Look it up on the imported application.
GET /customers/{customer_id}returnsapplication_id;GET /applications/{application_id}exposesprovider(personaorsumsub) andprovider_applicant_id(the Persona inquiry ID or Sumsub applicant ID). See Persona Token Import and Sumsub Token Import. - For Persona imports, correlate through the import job.
GET /customers/persona-import-jobs/{job_id}returns each redeemed token with itscustomer_idandapplication_id, so you can map the token you originally submitted back to a Dakota customer without keeping a separate table.
-
statusisreadywhen you can submit the application, orincompletewhenmissinglists what it still needs. -
missingis the same checkGET /applications/{application_id}?include=validationruns. Each item has atype:field: a value to set on theentity, for exampleemployment_status.document: a document to upload for theentity, with itspurposeand theaccepted_types.attestation: an attestation the application still needs.role: a person a business application still has to add.
entity.kindisindividual,businessoredd. -
required: falsemeans the item does not block submission. Proof of address is listed this way when it is optional, so you can still offer the upload. -
Expect
incompleteafter an import. A share token never carries Dakota’s own fields (employment_status,purpose_of_account,source_of_wealth) or the attestations. Supply them as described in Completing an Imported Application, then submit.customer.kyb_application.submittedfollows when you submit. - An identity document that came with the token counts as complete on its own, even without a back side.
- A token that fails to import sends no event. The payload never carries the application link or a token.
Request for Information (RFI) Events
During review, compliance may need more information about a customer you onboarded. The application stays open and that customer stays blocked until the information arrives. You own the relationship with your customer. Dakota never contacts them. These events are how you learn a customer is blocked and what is still owed, so you can collect it in your own product. customer.rfi.requested — a reviewer opened a request.requirements lists everything the customer must supply.
"reason": "poa_volume_limit_approaching" and one requirement for the individual’s proof of address: purpose individual_proof_of_address when none is on file, or the rejected document itself with status on_file (send a replacement). The customer stays approved and can keep receiving deposits up to the limit. Collect the proof of address the same way as any other request. A request a reviewer opens carries no reason. A deposit that would take the customer over the limit, and every later one until a valid proof of address is approved, is held with hold_reason: proof_of_address_required on the transaction (see Deposits held for proof of address above).
Reading requirements
Each entry is one thing the customer must supply.
A document requirement names what is needed in exactly one of two ways, never both:
document_type— a specific kind of document, e.g.bank_statement. Supply that document.purpose— a compliance requirement that several document types could satisfy, e.g.business_proof_of_address. Let your customer choose among the documents that satisfy it, rather than demanding one specific type.
entity says who a document belongs to: {"kind": "business"} for the business, {"kind": "individual", "id": "..."} for a named individual, or {"kind": "edd"} for enhanced due diligence.
status is missing when nothing is on file for the requirement, and on_file when a document is already there and the reviewer is asking for it again — send a replacement.
Answer every question. A resubmission that carries documents but leaves questions unanswered goes back to a reviewer who still cannot act on it. When require_document is true the answer must also carry an upload; a text-only answer leaves the request open.
Send each question’s key back as question_key. Every question has a key. A pre-set question has a named key, such as source_of_wealth. A question the reviewer wrote in their own words has a numeric key, such as "0" or "1". Copy the value you receive. Do not compute it, and do not match on prompt: the text is kept for display only, so an answer with the right text and a different key leaves the question open.
An event sent before every question had a key can show a question the reviewer wrote with no key. Read the key from rfi_requested_items in GET /applications/{id}?include=all (step 2 of Resubmitting through the API), which shows a key on every question of the open request.
status is scoped to the party in entity. A beneficial owner having supplied a proof of address does not make the business’s proof of address on_file.
message_id identifies the request. Use it to deduplicate redeliveries and to pair a later customer.rfi.responded with the request it answers.
requirements is always present and always an array. It is empty when a reviewer sent a plain request with no machine-readable scope; the request is still open and still needs a response.
Where to get the resubmission link
The event does not contain the resubmission link. Fetch it from the customer:resubmit_url is present only while a request is open: status is info_requested, or an approved individual has an open proof-of-address limit request (status stays approved). It opens a Dakota-hosted page that lists the same requirements and accepts uploads and answers. Forward it to your customer and the request closes when they submit.
Resolving a request
Both routes end the same way: the request is closed,customer.rfi.responded is raised, and the application is back with a reviewer. Forward the link when you want Dakota to collect the items, or do it yourself with Resubmitting through the API.
Resubmitting through the API
Collect the items in your own product and send them yourself, instead of forwarding the link. Four calls, in this order. The examples below are one real run end to end. Everything after step 1 authenticates with the application token, not your API key. Your API key is refused with403 on application routes: they accept a key only from Dakota’s own staff. Every write also needs an x-idempotency-key header carrying a UUID.
1. Read the customer to get the token
The webhook gives youcustomer_id. The link, and the token inside it, come from the customer.
token query parameter. Take it from resubmit_url, or from application_url when the reviewer’s request carries no resubmission scope and there is no resubmit_url.
2. Read what is outstanding
requirements in the webhook is the same list, so this step is optional. It is the one that tells you the state now, after anything your customer already sent.
Every question has a
key. The run above has only a pre-set question. A question the reviewer writes in their own words has a numeric key instead:
key in step 4:
3. Upload each requested document
document_type, file_type, file_content, filename and country are all required. For a file too large to inline as base64, take a presigned URL from Create an application document upload session.
4. Answer the request
This is the call that closes it.200 means the request is closed, the application is back in the reviewer’s queue, and customer.rfi.responded has been raised. It answers the reviewer’s request only, so the rest of the application does not have to be complete.
The uploads in step 3 are already attached to the application, so a request for documents alone needs nothing more than {} as the body. answers carries the reviewer’s questions, and document_ids ties an upload to the answer it supports.
A 400 means the answer does not complete the request, for example when a question has no answer under its key. detail lists what is still missing, such as Waiting on: an answer to 1 question, and nothing was recorded. Fix the answer and send it again.
A 409 means the reviewer decided the application while you were collecting: the window is closed and nothing was recorded. Watch customer.kyb_status.updated for the decision.
Retrying step 4 is safe as long as you reuse the same
x-idempotency-key: the original response is replayed and nothing is recorded twice. A repeat with a new key stores the same answers again, and the reviewer then reads the answer twice.reason is optional and is recorded for audit. Withdrawal is final: the customer’s status becomes withdrawn, and onboarding them later requires a new application. An application that already has a decision returns 409.
Handling the events
customer.kyb_status.updated for the approval or decline.
Do not treat “we sent the link” as resolution. The request stays open, and reviewers see nothing, until the customer actually responds. Drive any follow-up you do from customer.rfi.responded.
Application status changes that do not emit a webhook
declined transitions driven by KYC/KYB rejection do not emit their own application event; they arrive as customer.kyb_status.updated. If you need to track a state that has no event of its own, poll GET /applications/{application_id} and inspect application_status.
Verification Code Examples
Ed25519 verification implementations — pick your language. See the public keys and required headers in Webhook Signature Verification above.Managing Webhooks
List Webhooks
List all webhook targets configured for your account:cURL
Update Webhook
Update an existing webhook target:cURL
Delete Webhook
Delete a webhook target:cURL
Webhook Delivery
Retry Policy
Dakota automatically retries failed webhook deliveries using exponential backoff over approximately 48 hours:
Retry behavior:
- Max attempts: 10 total
- Total retry window: ~48 hours
- Timeout per attempt: 20 seconds
Success Criteria
A webhook delivery is considered successful when:- Your endpoint returns an HTTP status code in the
2xxrange (200-299) - Response is received within 20 seconds
- Your endpoint returns a non-2xx status code (4xx, 5xx)
- Connection timeout (20 seconds exceeded)
- Connection refused or DNS failure
Failure Handling
If all 10 delivery attempts fail:- Webhook is marked as permanently failed
- You can view failed webhooks in the dashboard
- Failed webhooks can be manually retried
Event Ordering
Webhooks are not guaranteed to arrive in lifecycle order. Two events fired close together for the same resource (e.g. twowallet.transaction.updated events with status_code broadcasted and completed on a fast testnet, or two transaction.auto.updated events for an instant rail (for example processing and completed)) may reach your endpoint out of order due to network races, retry timing, and the fact that the envelope created field is second-resolution.
This is most visible in sandbox because testnet broadcasts and the simulate endpoints transition states sub-second. In production, longer chain confirmations and bank-rail latencies usually space the events out enough that ordering is incidental — but the contract still does not guarantee it.
How to handle it:
- Treat the status as the source of truth, not the order of arrival (
status_codeon wallet transactions,statuson other resources). Each event carries the full resource state; the latest event by terminal-state semantics wins. - Use
X-Dakota-Event-IDfor idempotency — duplicate deliveries (retries or out-of-order replays) carry the same ID. - For ordered processing, derive a sequence from the resource’s own state machine. For wallet transactions, by
status_code:waiting_for_signature → broadcasted → awaiting_confirmation → completed | failed | reversed. For auto-account deposits:received → succeeded | failed. If you receive a “later” status before an “earlier” one, accept it; the earlier one is informational. - A deposit can be held more than once. A failed return, or a returned deposit moved back to held, sends
deposit.heldagain. Deposit events carry nostatus, so order a deposit’s events byheld_atandreturned_at. See Handling deposit events. - Don’t strictly require monotonic timestamps —
createdandX-Webhook-Timestampare second-resolution, and two events can share the same value.
wallet_id, transaction_id) and reorder by status before applying.
Webhook Headers
Every webhook request includes these headers:
Use
X-Dakota-Event-ID for idempotency - it remains the same across all retry attempts for the same event.
Best Practices
Endpoint Requirements
- HTTPS only: Dakota Platform only sends webhooks to HTTPS endpoints
- Fast response: Respond within 30 seconds
- 2xx status codes: Return 200-299 status for successful processing
- Idempotent: Handle duplicate webhooks gracefully
Security
- Always verify webhook signatures
- Use HTTPS for your webhook endpoint
- Validate event data before processing
- Log webhook events for debugging
Processing Best Practices
Implement robust webhook event processing with error handling, retries, and idempotency:Testing Webhooks
Local Development
Use tools like ngrok to expose local endpoints:Webhook Testing
Test your webhook endpoint manually:Troubleshooting
Common Issues
Webhooks Not Received- Check that your endpoint returns 2xx status
- Verify your URL is accessible from the internet
- Ensure HTTPS is properly configured
- Check firewall/proxy settings
- Ensure you’re using Dakota Platform’s correct Ed25519 public key
- Verify the signature calculation matches our Ed25519 implementation
- Check that the timestamp and payload haven’t been modified
- Ensure you’re using the correct header names (
X-Webhook-Signature,X-Webhook-Timestamp)
- Implement idempotency using the event ID
- Store processed event IDs to prevent duplicates
- Use database constraints where possible
Monitoring
- Set up alerts for webhook failures
- Log all webhook events for debugging
- Monitor endpoint response times
- Track webhook delivery success rates
Event Types Reference
Below are the commonly used webhook event types. This list is not exhaustive — the authoritative list of subscribable event types is theEventType enum in the API reference.
Next Steps
After setting up webhooks:- Testing Your Integration - Test webhook delivery and processing
- Transactions - Process transactions that trigger webhook events
- Customer Onboarding - Set up KYB status webhooks
API Reference
For detailed endpoint documentation, see:- Webhooks API Reference - Webhook target management
- Events API Reference - Event types and payload formats

