Developers

How an agency integrates with Actum, machine to machine.

There are two halves. Inbound, your software calls the same REST routes the console calls, authenticated with a scoped API key instead of a person's session. Outbound, Actum posts signed webhooks to an endpoint you register. Both ride one event spine, so what you receive is the same fact the console shows an operator.

Credentials are issued to vetted agencies from the console's Integrations tab. If your agency is not on Actum yet, start at the agency directory.

Inbound: API keys

Call the regular routes with Authorization: Bearer ak_live_…. A key is pinned to one agency, checked against a scope per route (bills:write, rent:collect, rentals:write, webhooks:manage, and so on), and shown to you exactly once at creation. Only a hash is stored, so a lost key is replaced, never recovered.

Machine traffic is rate-limited per key rather than per IP, so one integration behind a shared egress address cannot starve another. Audit rows attribute every action to api-key:<id> — which is why a key belongs to the agency and never to one person's account. Keys survive staff turnover; people do not.

Outbound: registering an endpoint

POST /api/agency/webhooks
Authorization: Bearer ak_live_…

{
  "agencyId": "ag_…",
  "url": "https://example-agency.com/actum/webhook",
  "topics": ["bill.issued", "rent.collected"]
}

The URL must be public HTTPS. Localhost, private ranges, and hosts resolving to reserved addresses are refused at registration, and the same check runs again before every delivery. Deliveries never follow redirects — a 3xx response counts as a failed attempt. The registration response carries the endpoint plus a signing secret (whsec_…), shown once. Only topics that actually fire can be subscribed to; a declared but not-yet-live topic is refused with a 400.

GET /api/agency/webhooks?agencyId=… lists your endpoints with their health (lastSuccessAt, lastError). DELETE /api/agency/webhooks/{id} disables one; its delivery history survives.

Delivery format

Each event is one POST to your URL. The JSON body is an envelope — the per-topic fields documented below arrive nested under data, not at the root:

{
  "id": "evt_…",       // stable per business event — dedupe on this
  "topic": "bill.paid",
  "data": { … }         // the per-topic fields listed below
}

Four headers ride along.

Actum-TopicThe event topic, for example bill.paid.
Actum-Event-IdStable per business event (evt_…). Two endpoints subscribed to the same event share it — dedupe on this.
Actum-Delivery-IdUnique per delivery row (whd_…) — one per endpoint per event, constant across that delivery’s retry attempts.
Actum-Signaturet=<unix-seconds>,v1=<hex> — see verification below.

Respond with any 2xx within 10 seconds. Anything else, including a timeout or a redirect, counts as a failed attempt.

Verifying the signature

v1 is HMAC_SHA256(signingSecret, "<t>.<rawBody>"), hex-encoded — the same scheme Stripe uses, and the same one Actum uses to verify Stripe inbound. t is unix seconds. Verify against the raw request body, before any JSON parsing.

import { createHmac, timingSafeEqual } from 'node:crypto';

function verifyActumSignature(header, rawBody, secret, toleranceSec = 5 * 60) {
  const m = /^t=(\d+),v1=([0-9a-f]+)$/.exec(header ?? '');
  if (!m) return false;
  const [, t, v1] = m;
  if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  return v1.length === expected.length &&
    timingSafeEqual(Buffer.from(v1, 'hex'), Buffer.from(expected, 'hex'));
}

Retries and dead-lettering

Deliveries are queued durably and attempted by a runner every five minutes. A failed attempt is retried on a backoff of 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, then daily — eight attempts in total. After the last failure the delivery is dead-lettered, visible to Actum operations, who can requeue it; the endpoint's lastError is updated.

Delivery is at-least-once. After a timeout your handler may have processed a body whose 2xx never reached us, so idempotency on Actum-Event-Id is required, not optional.

Parked rent collections

If POST /api/rent-collections answers {"ok": false, "parked": true}, the collection settled at the custody provider but Actum's posting failed afterwards. Retry it by re-posting the same sourceRef: the custody idempotency key rent:<sourceRef> dedupes the collection, so the money is never taken twice and the posting completes on the retry.

A fresh sourceRef is a new collection. Once a live payment rail is connected, it executes a second real direct debit against the tenant.

Topics

The field lists below are the contents of the envelope's data object. Every topic carries agencyId plus the ids an external system needs, never internal snapshots. Fields ending in Minor are integer minor units encoded as strings; fields ending in Usd are whole USD numbers.

Bills and money

bill.issuedA bill needs owner fundingbillId, propertyId, category, amountMinor, currency, counterparty, dueDate, funding
bill.paidA bill settled, by any methodbillId, propertyId, category, amountMinor, currency, counterparty, method, paidAt
bill.cancelledA bill was withdrawnbillId, propertyId, category, amountMinor, currency, counterparty, wasAccrued
rent.collectedA rent receipt ran the waterfallpropertyId, grossMinor, currency, managementFeeMinor, billsPaid, reserveContributionMinor, claimableMinor, sourceRef
rent.missedA rent period closed with no receiptpropertyId, periodKey, expectedMinor, currency

Shortfall cascade

shortfall.openedMust-pay arrears exceed the property's fundspropertyId, triggerBillId, requestedBillIds, currency
shortfall.cure_openedThe mandate's cure right activatespropertyId, cureDeadline, curePeriodDays
shortfall.forced_settlementThe deed is force-listedpropertyId, reason, askingPriceUsd
shortfall.resolvedArrears are covered againpropertyId, billsPaid

Verifications

verification.requestedA buyer pays for a verificationrequestId, propertyId
verification.scheduledThe inspection is scheduledrequestId, propertyId, scheduledDate
verification.report_publishedThe report goes public on the listingrequestId, propertyId
verification.needs_clarificationThe agency needs input from the ownerrequestId, propertyId
verification.failedThe verification cannot be completedrequestId, propertyId

Rentals

rental.proposal_createdAn agency proposes rental termsproposalId, propertyId, monthlyRentUsd
rental.proposal_acceptedThe owner accepts the termsproposalId, propertyId
rental.proposal_declinedThe owner declines the termsproposalId, propertyId
rental.management_stage_changedThe lease lifecycle advances. `active` means the lease commencedproposalId, propertyId, managementStage
rental.terminatedThe managed rental endsproposalId, propertyId

Digitization

digitization.requestedAn owner asks the agency to digitalize a propertyrequestId, contactName, contactEmail, contactPhone, locationQuery, currentlyRented, submittedAt
digitization.stage_changedThe pipeline advances a stagerequestId, stage
digitization.listing_readyThe bearer deed is minted and the listing is liverequestId, propertyId

Marketplace

listing.createdA deed is listed for salepropertyId, askingPriceUsd, forced
listing.delistedThe seller withdraws the listingpropertyId
listing.soldA sale settles, instant or by accepted bidpropertyId, priceUsd, method
offer.receivedA bid lands on a property you managebidId, propertyId, amountUsd
property.metadata_updatedProperty fields changepropertyId, fields (names only, never values)

Getting in touch

Integration questions go through your agency console. If you are already on Actum, open a request from the console and it reaches the same queue as everything else.