Overview
DALP posts an organisation's final money events to that organisation's Xero ledger. A background job turns each event the organisation's rule table maps, such as a trade settlement, a fee, a bank rail credit or cash-out, a paid dividend or the reversal of one of these, into a balanced Xero manual journal. It posts each journal once and keeps a posting record of what happened to every event.
The same events reach every webhook endpoint the organisation registers. An organisation that keeps its books in another ledger, or that wants its own posting logic, runs a subscriber that receives the signed feed and follows the rules on this page. For endpoint registration and the signature scheme in full, see Receive DALP webhooks.
How DALP posts to Xero
A job that runs once a minute across the deployment reads the organisation's committed events. The platform writes each committed event in the same database transaction as the change it describes, before any webhook delivery. The job needs no webhook endpoint, so an endpoint that is switched off or failing does not hold posting back.
For each event, the job does four things:
- It copies the event into a posting record, from the creation of the organisation's first stored configuration version onwards. Earlier events are never posted.
- It decides the event with the rule table of the newest configuration version, as From event to journal describes.
- It maps the journal's account keys to the Xero account codes in the configuration, checks the journal against the Xero organisation's base currency, and posts it as one manual journal with status
POSTEDand no tax. - It records the Xero journal id and the Xero organisation on the posting record.
A journal reaches Xero at most once for each record, across retries, crashes and configuration changes. Each DALP journal carries a narration marker built from the organisation and the event's source_id, and DALP sends Xero an idempotency key built the same way. Before DALP sends a journal again, it looks the marker up in Xero, and it records a journal Xero already accepted instead of posting a second one. Once a request may have reached Xero, every later attempt sends exactly that request to the same Xero organisation, even after a configuration change.
Do not edit or delete the narration of a DALP journal in Xero, because DALP finds its journals by it. A correction is a new journal, never an edit of a posted one.
The job works through one organisation after another and posts one record at a time for each organisation.
Connect Xero
Each organisation connects its own Xero app, so the deployment holds no shared Xero credentials. An organisation administrator who holds the webhook:manage permission connects it while signed in to the Console. An API key cannot connect, read or disconnect Xero.
- Create a web app for the organisation in the Xero developer portal, and note its client id and client secret.
- From a signed-in Console session, send them to
POST /api/.v2/ accounting/ xero/ connection - Add the
redirectUrithat DALP returns to the Xero app's redirect URIs, exactly as returned. It is the Console address followed by/.api/ v2/ accounting/ xero/ callback - Within ten minutes, open
authorizeUrlin the same browser and consent at Xero for the Xero organisation that holds the books. - Xero returns the browser to DALP. DALP completes the connection and redirects to
/on the Console.org- settings/ accounting?xero=<outcome> - Read the connection with
GET /api/, then store a posting configuration whosev2/ accounting/ xero/ connection mapping.xeroTenantIdis one of itstenantIds.
The connect request carries the Xero app's credentials. The values below are illustrative:
POST /api/v2/accounting/xero/connection
Content-Type: application/json
{
"clientId": "6F1C9A2B4D7E40A8B3C5D9E1F2A4B6C8",
"clientSecret": "<the Xero app's client secret>"
}DALP answers with the Xero consent URL and the redirect URI. It never returns the client secret:
{
"authorizeUrl": "<Xero consent URL carrying the client id, the redirect URI, the three scopes and a single-use state>",
"redirectUri": "https://your-instance.example.com/api/v2/accounting/xero/callback"
}DALP asks Xero for four scopes. offline_access keeps the connection alive, accounting. posts and finds manual journals, accounting.settings.read reads the Xero organisation's base currency and its chart of accounts, and accounting. reads the client-money balance for the bank rail daily reconciliation. A connection made before DALP asked for the balance-sheet scope keeps posting. Connect Xero again to add the scope.
A new connect replaces any connect still waiting for consent. The callback completes only for the signed-in user who started the connect, while that user still holds webhook:manage in the organisation, and each consent URL works once. Opened without a Console session, the callback answers 401 and leaves the consent URL usable until it expires.
On every outcome except connected, DALP discards the client secret it stored for the connect and revokes any token Xero issued. The outcome never carries Xero's own error text.
Connecting again with the same Xero user and the same app replaces the stored token. Connecting with a different Xero user or a different app also revokes the earlier grant at Xero.
Connection status and disconnect
GET /api/ reads the stored connection without calling Xero:
{
"state": "connected",
"tenantIds": ["8a3f2c71-5d4e-4b9a-a1c6-2e7f9b0d3c58"],
"lastRefreshedAt": "2026-11-10T09:15:00.000Z"
}state is connected, needs-reconnect or disconnected. needs-reconnect means Xero refused the stored grant, for example after someone removed the app's connection in Xero. Posting then pauses, and DALP raises a Xero connection needs attention case. Connect again to resume.
While it posts, DALP refreshes the access token about every 30 minutes. It also refreshes a connection that has nothing to post before Xero's 60-day refresh-token expiry. Each refresh rotates the refresh token, and DALP keeps the newest token even when it crashes between Xero's answer and its own write.
DELETE /api/ disconnects, and posting stops at once. DALP then revokes the grant at Xero, retrying every minute until Xero confirms, and deletes the token and the client secret only after that. After five unconfirmed attempts it raises a Xero connection needs attention case. An organisation with no connection gets 404 with DALP-20544.
Who can call what
Approvals describes how an organisation protects a group of changes.
Where the credentials live
The refresh token and the client secret never enter an accounting table, a log, an audit record or a response. DALP keeps them in the secret manager the deployment selects with secretManager.provider, as Secrets and key handling describes:
- With
local, DALP encrypts each secret under the deployment's master password and stores it in the platform database. - With
aws,gcp,azureorvault, each secret lives in your AWS Secrets Manager, Google Secret Manager, Azure Key Vault or HashiCorp Vault, under that store's controls and audit.
Every platform service that reads the secret manager can read these secrets. The organisation in a secret's name is a label, not an access control. On local, anyone who holds both database access and the master password can decrypt them. When DALP deletes a secret, Azure Key Vault keeps it recoverable for the vault's retention period, and the version 2 key-value engine of HashiCorp Vault deletes only the latest version. Request audit records redact the callback's state and code values.
Posting records
DALP keeps one posting record for every committed event of a configured organisation, not only for the event types its rules cover, so a rule added later still finds the earlier events. A record carries its status, a reason, the configuration version that decided it, the decided journal, and the Xero journal id and Xero organisation it posted to.
These rules apply to records:
- A newer configuration version decides every
unmappedrecord again on the next pass, so a corrected rule or account mapping posts what it blocked. - A replay returns a
failed,unmappedorignoredrecord topending. Apostedrecord is never replayed, and its correction is a new journal. - A record keeps the event payload until 30 days after it reaches
postedorignored, so apending,sealed,unmappedorfailedrecord can always be decided again. The decided journal stays for the record's whole life. A replay of anignoredrecord whose payload is gone is refused. - An event whose payload was pruned before DALP copied it is
failedwith reasonpayload-pruned, because DALP never decides an event from an empty payload.
Read and retry posting records
GET /api/ lists the active organisation's posting records, newest first. It pages with page[limit] and page[offset], 50 records to a page by default, and returns the number of matching records in meta.total. filter[status] takes one status, as in filter[status]=failed, or an eq, ne, inArray or notInArray predicate, as in filter[status][in. The list has one order, so a sort other than -createdAt answers 400 with DALP-9355. That is a caller error: drop sort or send -createdAt, and do not retry the same request.
Each record carries its status, a reason code, the last failure message in detail, the journal the rule table built with its narration, debit total and lines, and the Xero journal it reached: xeroJournalId for a record posted on its own, or dailyJournal with the day, sequence, status and Xero journal id of the daily journal it joined. attempts counts the posting attempts, replayCount the replays, and replayable says whether a replay would return the record to pending. The Console's Accounting page shows the same records, with each reason in plain words, and the Xero connection state.
POST /api/ returns a failed, unmapped or ignored record that still keeps its payload to pending. It clears the reason, the last failure and any daily journal the record was in. On its next pass the posting job decides an unmapped or ignored record again with the newest configuration version. A failed record whose journal DALP already sent to Xero keeps that journal: when the last send was less than six minutes ago, DALP sends the same journal again as built. Otherwise DALP first looks the journal up in Xero by its narration marker, records the record as posted when Xero holds it, and builds the journal again from the newest configuration version when Xero does not. Any other failed record is decided again with the newest configuration version. The Accounting page's “Retry posting” row action calls this route. The replay has no body, and the platform answers 200 with the record. The response below is illustrative:
POST /api/v2/accounting/postings/4f6b2c1e-8a3d-4e7f-9b25-0c6d1a8e3f72/replays{
"data": {
"id": "4f6b2c1e-8a3d-4e7f-9b25-0c6d1a8e3f72",
"sourceId": "chain:537001:0x3f9c2a7e5b1d84c06e2a9f4b7d3c1e8a5f6b2d9c0e7a4f1b3c5d8e2a6f9b0c4d:3:0:primary-market.subscription.final@1",
"eventType": "primary-market.subscription.final",
"eventVersion": 1,
"lifecycleState": "final",
"status": "pending",
"reason": null,
"detail": null,
"configurationVersion": 3,
"postingPolicy": "per-trade",
"journal": {
"narration": "primary-market.subscription.final chain:537001:0x3f9c2a7e5b1d84c06e2a9f4b7d3c1e8a5f6b2d9c0e7a4f1b3c5d8e2a6f9b0c4d:3:0:primary-market.subscription.final@1",
"occurredAt": "2026-10-12T09:15:00.000Z",
"amount": "24.99",
"currency": "USD",
"lines": [
{
"side": "debit",
"amount": "24.99",
"currency": "USD",
"accountKey": "investor-cash",
"classification": "trades",
"description": "Subscription"
},
{
"side": "credit",
"amount": "24.99",
"currency": "USD",
"accountKey": "subscription-liability",
"classification": "trades",
"description": "Subscription"
}
]
},
"xeroJournalId": null,
"xeroTenantId": null,
"dailyJournal": null,
"reversedBy": null,
"attempts": 2,
"replayCount": 1,
"replayable": false,
"createdAt": "2026-10-12T09:16:04.000Z",
"updatedAt": "2026-11-10T14:02:31.000Z"
},
"links": { "self": "/api/v2/accounting/postings" }
}A record that is already pending cannot be replayed again, so a second replay while the record is still pending answers 409 with in-progress. The platform writes an audit record for each replay. A refused replay names its cause:
Neither error is retryable, and neither means the record changed: a refused replay leaves the record as it was.
Posting policies
The configuration's postingPolicy is per-trade, the default, or daily-aggregate. DALP stamps the policy on each record when it decides the record, so a policy change affects only records decided after it.
Under per-trade, each record posts as its own journal, dated the UTC day of the event's timestamp.
Under daily-aggregate, a decided record waits as sealed for its UTC day. The day closes at 02:00 UTC the next day. DALP then builds one balanced journal dated that day from the day's sealed records and posts it:
- DALP sums the lines per account, side, tax type and tracking category, and a debit and a credit on one account net to one line.
- A journal that nets to zero is recorded as posted, and nothing is sent to Xero.
- A day whose total would reach ten billion in the ledger currency splits into more than one journal.
- A record decided after its day's journal was built goes into a supplementary journal for that day, with the same date. When Xero refuses it because the period is locked, the journal is
failedwith a case. - A record that cannot be mapped when the day closes is
unmappedwith a case, and the day's other records still post. - When Xero refuses a daily journal, the journal and its records are
failedwith a case. A replayed record joins the next journal for its day.
Reversals
A retraction such as a trade bust posts as the reversal of the journal its original posted. The reversal mirrors the original's lines on the same Xero account codes, even when the account mapping changed after the original posted.
- Under
per-trade, a reversal waits without a case until its original has posted. - Under
daily-aggregate, a reversal of an original in the same day's open journal joins that journal, so the two net out. A reversal on a later day waits until the original's daily journal has posted. - DALP reverses an original at most once.
- A reversal still waiting after one day of connected time raises an Accounting event blocked case. It keeps retrying, at most an hour apart, and posts once its original posts.
Operations cases
DALP raises an operations case whenever an event cannot post, and lists it with the organisation's other cases. Work an operations case describes how to acknowledge and resolve one.
Accounting event blocked is a warning. The other two are critical. DALP resolves a rate-limited case itself once Xero accepts a journal again.
Xero limits
Xero limits each app by its tier. A Starter tier app allows 1,000 API calls a day and 5 connected organisations, which fits a trial and not a production book. DALP spends about two calls on a post once the access token and the tenant check are cached, so a production organisation needs a higher Xero tier, or the daily-aggregate policy. When Xero answers 429, DALP pauses that organisation until Xero's retry time, 60 seconds when Xero names none, and the other organisations keep posting.
A Xero Demo Company resets every 28 days and gets a new tenant id. Posting then pauses with a Xero connection needs attention case until an administrator connects again and stores a configuration version with the new tenant id.
What DALP does not post
- Mirror-token issuance and burn. These events reach subscribed webhook endpoints only, not the organisation's committed events, so the posting job never sees them.
- A completed partial redemption or buyback. No event marks when its cash moves, so such an event ends
unmappedwith an Accounting event blocked case. - Events from before the organisation's first configuration version, and events whose payload was pruned before DALP copied them.
- Tax-bearing journal lines. A rule that produces one ends
unmapped.
Book these by hand, or through a custom subscriber.
Run your own subscriber
A custom subscriber posts what DALP does not: journals for a ledger other than Xero, and the events in What DALP does not post that reach webhook endpoints. It also reconciles the ledger's totals against DALP's read APIs on a schedule. When a subscriber and DALP both post to the same Xero organisation, keep their rules apart, or a movement posts twice.
The rest of this page is the contract for such a subscriber: what is on the feed, how to verify a delivery, the rule table, and the idempotency and replay rules. DALP's own posting job follows the same rule table.
A signed delivery is verified, branched on lifecycle_state and posted once per source_id, so a replay builds the same entry.
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:
The event catalogue and market and settlement events list the events the platform sends, their triggers, and the receiver's action. Payload schemas and examples are in the machine-readable event definitions and TypeScript SDK. The manifest groups the events into these families:
Bank rail events
Subscribe explicitly to bank-rail.credit.* or bank-rail.cash-out.*, because the default state patterns skip their milestone names. These events carry amount, currency and occurredAt, so they are journal candidates when your books have a rule for that milestone. An admitted or requested event records work in progress. Its envelope state final means the rail committed that observation, not that the payment completed.
Bank amounts are decimal currency values: "12.50" EUR is 12.50 EUR, rather than token base units. Convert them exactly to your ledger's minor units before applying the integer-amount rule below. Never feed a fractional bank amount to a rule that expects token base units.
Choose the posting milestone for each business movement. Posting the credit's admitted and minted events, or posting both its minted event and the token issuance or transfer, can count the same movement twice. A released cash-out can later report paid if the bank settles it late and the rail recovers and burns the tokens. Partial recovery sends no event. A cash-out's paid event can be followed by returned, which means the rail admitted the re-credit, not that it minted. The re-credit's admitted event identifies that cash-out through provenance.cashOutId. Wait for its minted event to book the completed mint.
A recall's recovered and written-down amounts, and each recorded claw, appear on the recall sheet on the Console's Payments tab. bank- arrives only when completed recalls cover the whole credit. Its amount is the completing bank reversal's amount, and earlier partial recalls send no credit status event. Read the recall records before constructing a journal for the whole reversal history. Events expose neither recovered amounts nor write-down figures, so they cannot supply those postings on their own. See the bank rail catalogue for each trigger.
Fee events
Fees arrive as their own events. For each token, post its fees either from the fee events or from the transfers that move them, never from both.
Custody gateway settlements are on the feed too: custody. and custody.. 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. 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. Your subscriber 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.
Bond payout events
A bond pays its coupons and its maturity redemption one holder at a time, when the holder claims or redeems, so the cash reaches the feed as one event per claim or redemption.
Both events are final only. DALP sends each one once the block of its claim or redemption reaches chain finality, so the platform never retracts it, and a replay of the same log sends no second event. amount is the cash paid to the holder, in base units of denominationAddress. A coupon claim's amount can be less than the sum of the periods from fromPeriod to toPeriod, so post amount and not a figure computed from the coupon rate. A redemption also carries tokenAmount, the redeemed tokens. featureAddress is the contract that paid, which is the bond itself for a legacy bond. The holder field is withheld from thin payloads. Converting accrued yield into tokens pays no cash and sends no event.
A bond coupon corporate action completes when it is created, and a maturity redemption completes when no tokens are left, so token. for either one moves no cash. Post the cash from the bond payout events and treat those completions as booked elsewhere.
When the denomination asset is a DALP token, the payment also fires token.transfer.final. For each bond, map either its bond payout events or the denomination transfers that pay its holders, not both.
An endpoint receives these events for claims and redemptions that reach finality after the platform started sending them. Earlier claims and redemptions never get one, and an indexer rebuild does not send them.
If your organisation stores its configuration from the preset described in Store a posting configuration, the preset posts both events in each quote asset of the ledger currency, with classification servicing. A coupon claim debits servicing. and a redemption debits servicing., and both credit client-. An event in another denomination matches no rule, so the subscriber refuses it. The preset ignores token. for bond coupons and maturity redemptions.
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 amount, tokenAddress and occurredAt, the time of the block that holds the transfer, so a rule can post it. A transfer from before occurredAt existed has no timestamp field, so your subscriber refuses it as malformed. primary- carries paymentAmount, paymentCurrencyAddress and occurredAt too. 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. Verify every delivery as Verify the signature describes, with the SDK's verifyWebhook or any Standard Webhooks library. 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
A rule table maps the organisation's events to journals. DALP's posting job reads the one in the organisation's stored configuration, and a custom subscriber keeps its own. 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, organisation-chosen name such as investor-cash. The organisation'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 valid only when all of these hold. Otherwise reject the whole table instead of fixing 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
Your subscriber refuses a final event with no matching rule, or a retracted event that reverses a posted entry but has no rule, with a named error rather than guessing. 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.
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 for token and asset events are integer strings in the asset's smallest unit. The rule's asset field names an entry in the organisation'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 the rule does not use are omitted:
{
"evt_id": "evt_7c12e838e3f9a8bafcaa292f86ed41e7",
"source_id": "chain:537001:0x3f9c2a7e5b1d84c06e2a9f4b7d3c1e8a5f6b2d9c0e7a4f1b3c5d8e2a6f9b0c4d:3:0:primary-market.subscription.final@1",
"type": "primary-market.subscription.final",
"version": 1,
"lifecycle_state": "final",
"payload": {
"saleAddress": "0x7777777777777777777777777777777777777777",
"paymentCurrencyAddress": "0x8888888888888888888888888888888888888888",
"paymentAmount": "1000000000",
"occurredAt": "2026-10-12T09:15:00.000Z"
}
}In the tables of this example, <source_id> stands for that source_id value. The organisation's rule table holds one posting rule for primary- 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:
DALP's posting job, or your own 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.
Store a posting configuration
An organisation keeps its rule table, chart-of-accounts mapping and posting policy in DALP with PUT /api/, and reads the newest version with GET. Who can call what lists the permission and approval each needs. The body names three things:
postingPolicy:per-trade, the default, ordaily-aggregate, as Posting policies describes.mapping: the Xero organisation'sxeroTenantId, its base currency asdefaultCurrency, the Xero account code for each account key per classification underaccounts, and anytaxTypesandtrackingCategories.rules: the rule table that From event to journal describes.
Each write creates a new immutable version. Send the version you read as expectedVersion, or null for the first one. A stale version answers 409, and repeating a write whose content matches the current version returns that version. A rule table that cannot post against its account mapping answers 422, with each problem in data.issues.
A write sends its rules either as a complete rule table or as preset parameters. The parameters list the organisation's quote assets, mirror assets, other assets and bank rail currencies, and give each quote asset, mirror asset and currency an account suffix. DALP expands them into a rule table that names each asset's accounts with its suffix, such as client-, and stores the expanded table in the version. A version keeps that table, so a change to the preset reaches an organisation only when it writes its configuration again. A version written before the preset covered bond payouts has no rule for token.coupon-claim.final or token., so those events are refused until the organisation writes its configuration again. That write needs servicing. in the servicing account mapping for every quote asset in the ledger currency, or it answers 422.
The first version starts the posting record intake: DALP copies the organisation's events from that version's creation onwards and posts them once Xero is connected. A record that may already have reached Xero keeps the version that decided it.
Reversals and idempotency
Two keys keep the ledger correct.
webhook-id deduplicates deliveries. The header carries the envelope’s evt_id. 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 organisation plus sourceEntryId posts it once.
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_. 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 organisation.
- 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 has not 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 organisation'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. While an endpoint is switched off, whether an administrator switched it off or its circuit breaker opened after 30 failed attempts in a row, the platform records no events for it, and neither Resend nor Replay recovers them. Reconcile your ledger totals against DALP's reads on a schedule instead of assuming the feed is complete.
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_ 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. The retention cleanup deletes a settled event 30 days after its delivery ended, so replay returns nothing for an event older than that. After a longer outage, rebuild the missing entries from DALP's read APIs instead. Replay cannot help when DALP never recorded a notification for your endpoint, as for the period an endpoint was switched off. 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
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 network's finality depth, so waiting for it avoids the reversal. trading- and trading- are the exception: the platform sends them when the settlement transaction confirms, before that depth, so reconcile trades against the chain before you close a period. 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 your 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.
Next steps
- 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.
- Work an operations case covers acknowledging and resolving the accounting cases.
- Secrets and key handling covers the secret manager that holds the Xero credentials.