Post DALP webhook events to a ledger
The signed webhook feed that carries DALP activity, and the journal, account-key, reversal and idempotency rules an external ledger follows to post it.
Overview
DALP produces a signed feed of platform events. It does not post to a general ledger. The customer or fund administrator runs a subscriber that receives the feed, turns each event into a journal, and posts that journal to Xero or any other ledger. This page is the contract that subscriber needs: what is on the feed, how to trust it, how an event becomes a balanced journal, and how to keep the ledger correct when deliveries repeat, arrive late, or are undone.
The feed is the same webhook stream every integration receives. The accounting rules below sit on top of it; they add no second channel. For endpoint registration and the signature scheme in full, see Receive DALP webhooks.
What is on the feed
An endpoint receives the events it subscribes to. Each delivery is one JSON envelope with these fields the ledger side reads:
| Field | Meaning for a subscriber |
|---|---|
evt_id | Identifies this delivery of the event. A replay of the same occurrence carries a new evt_id. |
source_id | Identifies the underlying occurrence. It stays the same across replays. Not every event carries one. |
type, version | The event name and its schema version. Match rules on both. |
lifecycle_state | One of pending, provisional, final, retracted, recalled. |
payload | The typed event data, including amounts and timestamps. |
supersedes | On a retraction, the evt_id of the event it replaces. |
is_replay | true on an envelope created by a replay request. |
is_test | true on a test event. Never post it. |
The hand-written event catalogue lists the events the platform sends, their triggers, and the receiver's action. It is checked against the registry. Payload schemas and examples are in the machine-readable event definitions and TypeScript SDK. The feed covers these subject areas today:
| Family | What it reports |
|---|---|
token | Transfers and corporate actions with their retractions, token fees and fee period closes |
asset | Asset creation, issuance, burns, pause and unpause |
primary-market | Token sale subscriptions, deliveries, refunds and proceeds withdrawals |
trading-venue | Orders, trades, trade settlement and their busts |
xvp | Cross-party settlement lifecycle |
compliance | Freezes, unfreezes and verification verdicts |
identity | Registration, claims and status |
access-control | Role grants and revocations |
custody | Account creation, signer changes, custodial cash movements and withdrawal fees |
funding | Funding requirements for operations |
Fee events
Fees arrive as their own events. Post a fee from its fee event, never from a transfer leg.
| Event | What it reports | Fee id |
|---|---|---|
trading-venue.trade-fee.final | The venue maker or taker fee on a settled trade. | <tradeId>:<side> |
trading-venue.trade-fee.retracted | A trade bust reversed that fee. | <tradeId>:<side> |
custody.fee.final | The fee on a completed custodial withdrawal. | <holdId>:withdrawal-fee |
token.fee.final | One token fee charge: transaction, external, AUM, accrual or fund management fee. | <blockNumber>:<logIndex>:<feeType> |
token.fee-period-close.final | One accrual period close. | periodCloseId, <blockNumber>:<logIndex> |
Custodial cash events are on the feed too: custody.deposit.final, custody.deposit.retracted, and custody.withdrawal.final. The custody event catalogue describes their triggers.
A token fee event is final only. DALP sends it once the chain finality watermark reaches its block, so the platform never retracts it. The basis field is collected or accrued, and a rule can branch on it with when. The payer field is withheld from thin payloads. The fees-and-charges report row for a token fee has the subject reference <feeId>:<payer>, with an empty payer part when the fee has no payer. A fee id is unique within one chain only, so a ledger that takes events from more than one chain keys token fees on source_id.
A transaction, AUM or management fee also moves tokens on chain, so token.transfer.final or asset.issuance.final fires for the same value. An external fee paid in a DALP token fires token.transfer.final too. A rule table cannot exclude one transfer leg, so a ledger that maps token.transfer.final for a token also books its fees through that event. For each token, map either its fee events or its transfers, not both.
token.fee-period-close.final reports the on-chain sum of every accrual since the previous close in amount. No tokens or cash move at a close. A ledger that posts accruals to a receivable moves that total on at the close. An accrual refused for not being a whole number of cents leaves the receivable short against the close until someone posts it by hand.
Most token fees are denominated in the token itself. Listing a security token in a rule table's assets values it at par, one token per ledger currency unit. Leave such fees unmapped until a valuation exists. The rule engine never rounds, so it refuses a fee amount that is not a whole number of cents. Basis-point fees on 18-decimal tokens are usually not whole cents.
An endpoint receives these events from its deployment onward. The next indexer rebuild also sends them for every past fee log at or after the endpoint's start block. Deduplication on source_id posts each fee once. A ledger that already booked those fees from the period reports should exclude the earlier period.
Not every event can become a journal. A journal needs an amount in the payload and an ISO 8601 timestamp for when it happened. For example, token.transfer.final carries an amount and a token address but no timestamp field, so a rule cannot post it as is. primary-market.subscription.final carries paymentAmount, paymentCurrencyAddress and occurredAt, so it can. Check the event's schema in the machine-readable event definitions before you write a rule for it.
Verify and accept a delivery
Treat the signature as the gate before any posting logic runs. DALP signs every delivery with the Standard Webhooks scheme and sends webhook-id, webhook-timestamp and webhook-signature, plus webhook-delivery-id and webhook-endpoint-id. Verify the signature against your stored signing secret with a Standard Webhooks library, and reject a timestamp outside a five-minute window. The signed content is the exact raw body, so verify before you parse it. While a secret rotates, webhook-signature can hold two signatures; either one is enough.
Answer with a 2xx as soon as you have stored the event durably, then post to the ledger afterwards. A reply slower than 20 seconds counts as a failure and triggers a retry of work you may already have done. The response and retry table and counter-signed receipt contract define the delivery response. Receive DALP webhooks covers secret rotation.
A signature proves DALP sent the message and that nobody altered it. It does not tell you the event is final. Read lifecycle_state next.
From event to journal
An organization keeps a rule table that maps events to journals. Two kinds of rule exist.
A posting rule names an event type and version, an optional exact-match condition on payload fields, a classification, a debit account key and a credit account key. It also names the payload fields that hold the amount, the asset, the timestamp, an optional stable domain key, and any reference fields to copy into the description. The classification is one of trades, fees, servicing or corrections.
A reversal rule names the event type it undoes and the payload field that identifies the original.
An account key is a stable, organization-chosen name such as investor-cash. The organization's account mapping resolves each key to a real ledger account, per classification, in Xero or elsewhere. Rules never carry ledger account codes, so a chart-of-accounts change touches the mapping and not the rules.
A rule table is only valid when all of these hold. Reject the whole table otherwise; do not fix it up silently.
- Every account key resolves in the account mapping for the rule's classification.
- No two rules can match the same event, and a type does not have both posting and reversal rules.
- Every reversal rule targets a type that has a posting rule, and both key on the same field.
- Every asset's currency is the ledger currency.
- No posting rule targets a pending or provisional event.
Which events post
| Lifecycle state | What the subscriber does |
|---|---|
Test event (is_test) | Skip. |
pending, provisional | Skip. These can still be retracted, so they never post. |
final | Apply the matching posting rule. |
retracted | Apply the matching reversal rule, if the organization wrote one. A retraction of a pending or provisional event needs no rule: nothing was posted, so acknowledge it and do nothing. |
recalled | An operator withdrew an earlier delivery and left the original event intact. It is not an accounting reversal. Do not post a compensating journal unless the organization's finance team decides to and writes a rule for it. |
A final event with no matching rule, or a retracted event that reverses a posted entry but has no rule, is refused with a named error rather than guessed at. Treat that refusal as a signal to extend the rule table, and keep the delivery for later. Acknowledge test events, recalled events, and every pending or provisional event as handled no-ops, including the retraction of a pending or provisional event, since nothing was posted for it. Refusing those would only cause retries.
The journal shape
A posting produces one balanced entry with two lines.
| Entry field | Value |
|---|---|
sourceEntryId | The envelope's source_id. |
occurredAt | The rule's timestamp field. |
narration | <type> <source_id>. |
| Debit line | The debit account key, the amount, and sourceLineId of <source_id>:debit. |
| Credit line | The credit account key, the same amount, and <source_id>:credit. |
Each line carries the classification, the ledger currency and a description made of the rule's text plus field=value pairs for the reference fields. Because both lines carry the same amount, the entry always balances.
Amounts
Payload amounts are integer strings in the asset's smallest unit. The rule's asset field names an entry in the organization's asset table, which gives the ledger currency and the asset's decimals. The subscriber converts exactly to cents with integer arithmetic. It never rounds. It refuses any amount that is zero, is not a whole number of cents, or is ten billion or more in the ledger currency. A refused amount needs a human decision: a sub-cent amount in the ledger currency is a rule or asset-table problem, not something to round away.
Worked example: one subscription
An investor subscribes to a token sale. DALP sends this event once the chain reaches finality for its block (fields not used below are omitted):
{
"evt_id": "evt_7c12e838e3f9a8bafcaa292f86ed41e7",
"source_id": "<source_id>",
"type": "primary-market.subscription.final",
"version": 1,
"lifecycle_state": "final",
"payload": {
"saleAddress": "0x7777777777777777777777777777777777777777",
"paymentCurrencyAddress": "0x8888888888888888888888888888888888888888",
"paymentAmount": "1000000000",
"occurredAt": "2026-10-12T09:15:00.000Z"
}
}The organization's rule table holds one posting rule for primary-market.subscription.final at version 1. It uses classification trades, debit key investor-cash, credit key subscription-liability, amount field paymentAmount, asset field paymentCurrencyAddress, timestamp field occurredAt and reference field saleAddress. Its asset table maps 0x8888…8888 to currency USD with 6 decimals.
The subscriber computes the amount: 1,000,000,000 base units at 6 decimals is 1000.00 USD, exactly 100,000 cents. It then builds this entry:
| Field | Value |
|---|---|
sourceEntryId | <source_id> |
occurredAt | 2026-10-12T09:15:00.000Z |
narration | primary-market.subscription.final <source_id> |
| Debit | investor-cash, 1000.00 USD, line id <source_id>:debit |
| Credit | subscription-liability, 1000.00 USD, line id <source_id>:credit |
The ledger connector then resolves investor-cash and subscription-liability through the trades account mapping and posts the entry. In Xero, that is one manual journal.
Reversals and idempotency
Two keys keep the ledger correct. Use each for its own job.
webhook-id (the evt_id) deduplicates deliveries. A retry repeats the same webhook-id, so record the ones you have handled and answer 2xx for any you recognise.
source_id makes the posting idempotent. A replay creates a new evt_id for the same occurrence, so evt_id alone would post it twice. Because the journal's sourceEntryId is the source_id, a replay produces the identical entry, and a ledger connector that keys on the organization plus sourceEntryId posts it once. An event with no source_id is refused, since a replay of it could not be detected.
Treat a posted entry as immutable. Never edit it to correct it. A correction is a new entry with a new source id.
The reversal key
Each posting also has a reversal key. It is <type>#<value> where the value is the payload field the rule names as its domain key, for example a trade id. When a posting rule declares no domain key, the key is <type>#source:<source_id>. Store the reversal key with the entry you post, because a reversal looks the original up by it.
A reversal event, such as a trade bust, names the original through a payload field that holds the same value as the original's domain key. A rule is only valid when both events use the same field. Many retractions carry only supersedes, the evt_id of the original, and no such field. A reversal rule cannot express those, because it must name a payload field. Handle them in the subscriber outside the rule table: look up the event named by supersedes in your stored deliveries and use its stored reversal key. The subscriber then:
- Builds the key
<reversed type>#<value read from the retraction's payload>, or takes the stored key of the event named bysupersedes. - Looks up every entry posted under that key for this organization.
- Posts one new entry whose lines mirror the originals: debit becomes credit and credit becomes debit, same account keys, same amounts, line ids
<original line id>:reversal, and descriptions starting "Reversal of". - Uses the retraction's own
source_idassourceEntryId, with narration<type> reverses <reversal key>.
If the lookup finds nothing, decide by whether the original can still arrive. When the event named by supersedes is one your endpoint has stored but not yet posted, or a delivery may still be in flight, keep the reversal queued and retry it yourself after you acknowledge the delivery, because deliveries can arrive out of order and DALP does not resend one you acknowledged. Once the original turns out to be a pending or provisional event, which never posts, mark the retraction handled and stop retrying. When no such event exists for your endpoint, there is nothing to reverse: keep the retraction, log the missing original, acknowledge with a 2xx and post nothing. Never post a reversal for another organization's entry.
Delivery, retries, ordering and replay
DALP delivers at least once, never exactly once. The retry contract defines response classes, backoff, and the three-day retry window.
Ordering is per endpoint. A later eligible event waits while an earlier event is pending, including during backoff. A linked correction waits until its cause was delivered or can no longer be delivered. Finality waits do not block later events, and Replay can send an older observation after a newer one. Keep the ledger safe when a retraction names an event you never received, or when posting runs after acknowledgement. The ordering contract defines those limits.
Gaps are possible. For token movement, if DALP cannot write the notification, it still commits the movement and logs the failure, so a delivery can be missing without any signal on your side. Reconcile your ledger totals against DALP's reads on a schedule instead of assuming the feed is complete. The reasoning behind this is in Platform Event.
Replay recovers recorded events. The Resend and replay reference defines the recovery calls. Replay selects events the platform recorded for an endpoint, by a block range or one evtId. A replayed envelope has a new evt_id, is_replay: true and the original source_id, so replaying is safe for a subscriber that keys on source_id. Use it after a subscriber outage longer than the three-day retry window, or for a delivery that ended in a terminal failure. DALP keeps recorded events for a finite retention period that the deployment sets, so replay returns nothing for an event older than it. After a longer outage, rebuild the missing entries from DALP's read APIs instead. It cannot help when DALP never recorded a notification for your endpoint, as in the token movement case above. If a replay of the affected range returns nothing for an event your reconciliation shows is missing, the ledger stays incomplete until you obtain the data from DALP's read APIs or your DALP contact.
Frequently asked questions
Can I let DALP post to Xero for me?
No. DALP produces the signed feed and stops there. The posting step, the Xero credentials and the chart-of-accounts mapping stay with the customer or fund administrator.
Why not post provisional events and correct them later?
A provisional event can be retracted by a chain reorganisation. Posting it means posting a reversal later and showing both in the ledger. A final chain event is sent only after its block reaches the finality depth configured for the chain, so waiting for it avoids that churn. A final event can still be undone by a business reversal such as a trade bust, which is what the reversal rules cover.
What if my rules need a field the thin payload removes?
An endpoint's default thin payload shape strips personal data fields. Amounts and timestamps stay. If a rule reads a field that thin strips, the machine-readable event definitions identify it. Follow the payload shape contract to acknowledge the field and change to the full shape, or choose a different field.
Related
- Receive DALP webhooks covers endpoint registration, the signature headers and secret rotation.
- Webhook Delivery explains retries, the circuit breaker and delivery evidence.
- Platform Event explains the lifecycle states and the event identifiers.