Integration reference

Partner Push API

How a sales platform puts a sale, its banking and its documents into PropPay LX — and what the buyer receives at each step.

Base URL
https://console.proppaylx.app/api/v1/partner
Authentication
Bearer token + request signature
Content type
application/json
Error shape
{ error, message }
On this page

Authenticating a request

Two layers. The token says who you are; the signature says this exact request came from you and has not been replayed.

A note on the path

/api/v1/salefish is the same API under its original name and stays live indefinitely, as do the salefish* spellings of the identifier fields. New integrations should use /partner and the external* names.

1. Bearer token — required

Authorization: Bearer {your API secret}

Anything else is 401 invalid_token.

2. Request signature

An HMAC-SHA256, keyed with the signing secret issued alongside your token, sent in one header. There are two versions. Use v2. v1 is still accepted so existing integrations keep working, but it will be switched off.

New v2 — method, path and a single-use nonce

X-PropPay-Signature: t=1755624000,nonce=<nonce>,v2=<hex digest>

signed payload = "v2." + t + "." + METHOD + "." + path + "." + nonce + "." + rawRequestBody
key            = your signing secret (shared out of band)
PartWhat it is
tUnix time in seconds.
METHODThe HTTP method in capitals: POST.
pathThe request path exactly as you send it, including the /api/v1/… prefix and without the query string: /api/v1/partner/sales/48210/cancel. A signature made for /api/v1/partner/… is not valid on /api/v1/salefish/…, and the reverse.
nonceA fresh random value for every request: 16–128 characters of A–Z a–z 0–9 _ - (no full stops). 16 random bytes in base64url or hex is right. Never reuse one, including when you retry.
rawRequestBodyThe exact bytes you send (empty for an empty body).

Each nonce is accepted once per key. Sending the same signed request twice answers 401 invalid_signature the second time, so a retry must be re-signed with a new nonce (and, normally, a new t).

// Node
const t = Math.floor(Date.now() / 1000);
const nonce = crypto.randomBytes(16).toString("base64url");
const path = "/api/v1/partner/sales/48210/cancel";
const body = JSON.stringify({ reason: "Buyer withdrew" });
const v2 = crypto.createHmac("sha256", signingSecret)
  .update(`v2.${t}.POST.${path}.${nonce}.`)
  .update(body)
  .digest("hex");
// X-PropPay-Signature: t=<t>,nonce=<nonce>,v2=<v2>

A header that carries v2 is judged on v2 alone; a v1 value beside it is ignored, so you can send both while you migrate without weakening anything.

v1 — timestamp and body only (deprecated)

X-PropPay-Signature: t=1755624000,v1=<hex digest>

signed payload = "<t>." + rawRequestBody

v1 covers neither the method, the path nor a nonce, so a captured request can be replayed for up to five minutes and its signature is valid on any route with the same body. It is still accepted, and each use is counted per key. Once no key has used it for a full cycle it will be refused with 401 invalid_signature, “v1 signatures are no longer accepted”.

Sign the bytes you send

The digest covers the raw body, not a re-serialized copy of it. If your client pretty-prints its JSON, sign the pretty-printed bytes — PropPay verifies against exactly what arrived on the wire.

Requests more than 5 minutes old are refused as replays, so keep the sending clock in sync.

ResponseWhat to check
401 invalid_signature
missing
No X-PropPay-Signature header, or a v2 header without all of t, nonce and v2.
401 invalid_signature
stale
Your clock, not your key. The timestamp fell outside the 5-minute window.
401 invalid_signature
mismatch
The digest does not match the request received — usually signing a re-serialized body, the wrong secret, or (v2) a path or method that differs from what was signed.
401 invalid_signature
nonce must be…
(v2) The nonce is too short, too long, or has a character outside A–Z a–z 0–9 _ -.
401 invalid_signature
nonce has already been used
(v2) This exact signature was already accepted once. Re-sign with a new nonce.
401 invalid_signature
v1 … no longer accepted
v1 has been switched off. Sign with v2.
503 auth_unavailable (v2) PropPay could not check the nonce, so it refused the request rather than accept it unchecked. Retry.

Signatures are required by default on every key, because the token and the signing secret are issued together. A key can be issued with signing switched off for a partner who has not built it yet; once you have, ask for a second key with it on and have the first revoked. There is deliberately no header, flag or payload field that skips verification on a key that requires it.

Only failed authentication counts against the rate limiter. Your legitimate 404s, 409s and 422s are never throttled at any volume.

Which projects accept a push

Only projects an operator has explicitly switched to API mode in PropPay LX. Everything else is refused.

Each PropPay project takes its data from exactly one direction, never both. Either PropPay pulls on a schedule and the upstream platform is the source of truth, or your system pushes over this API and PropPay owns the record from then on. Turning the API on for a project is what takes it off the pull.

One safety net stays on. Every 30 minutes (7 a.m.–7 p.m. Toronto) PropPay still reads your sales list for an API project, and imports only what a push should have delivered and did not: a sale it has no record of, once its transactionDate is an hour old, and a transaction we cancelled that you have since sold again (see POST /sales). A sale you pushed is never rewritten by it. A missed push is caught within the hour, but the push is still how a sale should arrive: it is immediate, and its response tells you what was stored.

Pushing to a project that has not been switched over returns 403 api_not_enabled. That is an operator action in PropPay LX, not something the API can change.

Routing to the right builder

externalProjectId names a source in your system, and a source can feed several builders — one community in our data feeds three — so a sale on one must also carry companyId.

SituationResponse
Project is not switched to API mode403 api_not_enabled
No PropPay project wired to that external id404 project_not_found
Source feeds several builders, no companyId422 sale_unrouted
companyId not mapped to a builder on that source422 sale_unrouted
Project is archived in PropPay LX409 project_archived

Reading a 422

It means we know the community but not the builder. Surface it to an operator rather than retrying — no amount of retrying will map a companyId. PropPay’s own operators are alerted too: it is a buyer nobody can service until the builder is mapped.

One key per builder

Each PropPay project has its own key, and a key opens its own project and nothing else. On a multi-builder source that means one key per builder, not one per externalProjectId: every call must use the key of the project that owns the sale’s companyId. A single key used for the whole community answers 403 project_scope for every builder but its own — on /sales and on the by-sale routes (/pad, /documents, /cancel, /deposits) alike.

companyIdBuilderUse the key issued for
114Zancorthe Zancor project (LX0017)
4605Centurionthe Centurion project (LX0018)
112Fernbrookthe Fernbrook project (LX0019)

Choose the key from the companyId you are about to send, and keep using it for every later call on that sale.

Send the builder’s id from your companies roster — for Centurion that is 4605. Your SalesList stamps Centurion’s sales 3113, so we accept either for Centurion.

Identifiers

Every id in this API is yours, not ours. PropPay's own UUIDs are never accepted as input; they appear only in responses.

FieldIsLegacy alias
externalProjectIdYour project or community idsalefishProjectId
externalTransactionIdYour sale id — the :id in every path belowsalefishTransactionId
externalUnitIdYour unit or lot idsalefishUnitId
companyIdYour builder id on a multi-builder source—

Send either spelling — the neutral one wins if both are present. Numeric ids are fine; they are read as strings.

Order of operations

Steps two and three are independent and can arrive in either order, minutes or days after the sale.

  1. POST /sales Sale created, unit marked sold, deposit schedule seeded. Nothing is emailed.
  2. POST /sales/:id/documents The APS is filed against the sale, visible to staff and to the buyer.
  3. POST /sales/:id/pad The PAD agreement is generated unsigned, every account holder is emailed a link to sign it, and the purchaser gets their confirmation and schedule.

Addressing

:id is always your own externalTransactionId — never a PropPay UUID. Every id in this API is yours; ours appear only in responses.

POST /sales Already live

Creates the sale, its purchasers and its deposit schedule, and marks the unit sold. Emails nobody.

This call comes first

Nothing else in this API can address a sale until /sales has created it. /pad, /documents, /cancel and /deposits all resolve :id against the externalTransactionId you send here — pushing to any of them for a transaction PropPay has never been told about returns 404 sale_not_found, and nothing is stored or queued.

Request

{
  "externalTransactionId": "314363",
  "externalProjectId":     "A0035",
  "externalUnitId":        "23885",
  "transactionDate":       "2026-08-31",
  "basePrice":             749900,          // dollars
  "totalPrice":            764900,
  "closingDate":           "2027-06-15",
  "purchasers": [
    {
      "id":        "88214",
      "firstName": "Jane",
      "lastName":  "Doe",
      "email":     "jane.doe@example.com",
      "phone":     "905-555-0134"
    }
  ],
  "deposits": [
    { "index": 1, "title": "On signing",   "amount": 25000, "dueDate": "2026-09-07" },
    { "index": 2, "title": "30 days",      "amount": 25000, "dueDate": "2026-10-01" },
    { "index": 3, "title": "90 days",      "amount": 25000, "dueDate": "2026-11-30" },
    { "index": 4, "title": "On occupancy", "percentage": 5, "dueDate": "2027-06-15" }
  ]
}
FieldRequiredTypeNotes
externalTransactionIdyesstring | numberYour sale id. Becomes the :id in every later path. 400 invalid_payload without it.
externalProjectIdyesstringYour project or community id.
externalUnitIdyesstring | numberYour lot or unit id.
companyIdif multistringOnly where the source feeds several builders. Omit it on a single-builder source.
transactionDatenodate stringAnything parsable as a date; stored as YYYY-MM-DD. Unparsable becomes null, never an error. Also what tells a re-sold transaction from a replay, so move it when a deal is re-written — see Retries.
basePricenonumber | stringDollars, not cents. Omitted becomes 0.
totalPricenonumber | stringDollars. Omitted becomes 0.
closingDatenodate stringAs transactionDate.
purchasersnoarrayOmitted creates the sale with no purchaser, and nothing can be emailed at /pad later.
depositsnoarrayAlso accepted as deposit_schedule (the name used by the v1.0 integration spec) or depositSchedule. Omitted creates the sale with an empty schedule.

Money is in dollars

basePrice, totalPrice and every deposits[].amount are read as dollars and converted on arrival. 749900 means $749,900.00. Sending cents multiplies every figure by a hundred and will not error — it simply prints wrong on the buyer's confirmation.

Anything not in that table is ignored. firmDate, irrevocableDate, occupancyDate, discount, isInvestor, isFirstTimeBuyer, isForeignBuyer and purchasePurpose are real columns on our sale, but this endpoint does not read them — they are only ever filled by the scheduled pull, which on an API-mode project only imports sales you never pushed. Sending them is harmless and does nothing.

purchasers[]

FieldRequiredTypeAlso accepted asNotes
idnostring | numbersalefish_purchaser_idYour purchaser id, kept for later matching.
firstNamenostringfirst_nameProper-cased on the way in, so JANE stores as Jane.
lastNamenostringlast_nameProper-cased.
emailnostring—Lower-cased. The first purchaser's address is the only place the purchase confirmation is later sent, at /pad.
phonenostringcellNormalised to E.164 — 905-555-0134 becomes +19055550134. phone is the primary; cell fills in when only it is sent.
addressnostring—Street address. Proper-cased.
citynostring—Proper-cased.
provincenostring—Code or full name. ON stays ON; ontario becomes Ontario.
postalCodenostringpostal_codeNormalised to L9T 8M7.
sin———Do not send. Ignored since 2026-10-06 and never stored: PropPay LX has no use for a SIN. Sending one is not an error.

Every field is optional and nothing in this array can fail the request — a purchaser is stored as far as it parses, so a misspelled key is dropped in silence rather than reported. The first element is the purchaser of record: they receive the confirmation when an agreement is generated, and they are the fallback for whatever accountHolder block you omit at /pad.

Send the whole purchaser, not just the id

The four address fields are what the PAD agreement prints in Purchaser information — its Address, and alongside them the Phone and Email rows. Those come from the purchaser record and not from the accountHolder block you send at /pad, because the account holder is not always the purchaser. A sale pushed with only purchasers[0].id produces an agreement with a name and an otherwise empty contact block, and a buyer PropPay cannot identify anywhere else either.

deposits[]

FieldRequiredTypeNotes
titleyesstringThe only required field in the array. Blank or missing returns 400 naming the index: deposit[2] missing required 'title'. Shown to the buyer verbatim.
amountnonumber | stringDollars. Omitted or unparsable becomes 0.
percentagenonumber | stringDisplay only — it does not compute amount. A row with a percentage and no amount is a $0 deposit.
dueDatenodate stringAlso accepted as due_date. Also seeds the pull date, which an operator can move later.
indexnonumberAlso accepted as deposit_index or depositIndex. Ordering; defaults to position in the array, counting from 1.

Every row lands as a PAD deposit and stays at awaiting_pad until an agreement is signed. Send the schedule in full — the buyer's confirmation prints exactly these rows.

One project rule can rewrite a row

Where first deposit is a bank draft is switched on for the project, the lowest-index deposit is stored as a received bank draft instead of a PAD debit, so PropPay never tries to pull it. Nothing about your request changes; the schedule just reflects it.

A schedule sent under any other name is refused

If the payload carries an array whose key looks like a schedule but is not one of the three names above — payment_schedule, depositList, anything — the request is 400 invalid_payload naming the key, and nothing is created. This used to be silent: the rows were dropped, 201 came back, and the sale was created with an empty schedule.

Response

201 Created

{
  "id":           "4254815b…",
  "pad_required": true,
  "deposits":     4,                 // schedule rows actually stored
  "superseded":   ["A00035-19"]     // only when a live sale on the lot was cancelled
}

200 OK — this transaction id is already on file

{ "id", "pad_required", "idempotent": true }

Assert deposits against what you sent. A 0 there is the shape that produced a sale with no schedule and a PAD authorizing nothing.

Errors

CodeStatusMeaning
invalid_payload400No externalTransactionId, or a deposit with no title.
api_not_enabled403This project is not switched to API mode.
project_scope403This key was issued for a different project.
project_key_required403Platform token used on a project that has its own key.
project_not_found404No PropPay project wired to that externalProjectId.
sale_unrouted422Source feeds several builders; companyId missing or unmapped.
project_archived409Project is archived in PropPay LX.
unit_not_found404No such lot on that project, even after pulling your source.
unit_not_synced409A pull was already running. Retry shortly.
sale_create_failed500Ours. Safe to retry.

Retries

Re-posting a known externalTransactionId returns 200 with idempotent: true.

Except a transaction whose deal has ended here that you have sold again

A deal has ended when the sale holding that externalTransactionId is cancelled in PropPay LX, or when you told us it was cancelled (POST /sales/:id/cancel, or your sales list calling it cancelled) and nobody here has answered yet. If your transactionDate is later than that moment — later than the day, for a bare date — the push is a new deal on the same id. It is created as a new sale (201). A sale still waiting for an answer is cancelled first, like any older sale on a re-sold lot, and named in superseded. Either way the old sale keeps its buyers and history under a retired id, <id>/cancelled-<date>. The original date, or the same day, is read as a replay of the deal that ended and answers 200 idempotent: true as before — a cancelled sale is never brought back. Re-writing a deal should move its transactionDate.

A replay that names a different builder

A corrected companyId on a sale we already hold is still answered 200 idempotent: true, with the sale left where it is. This API never moves a sale between builders, because a PAD and the buyer’s banking may already stand behind it; PropPay’s operators are alerted, once, to confirm the builder with you. The corrected builder’s key cannot open the old builder’s sale (403 project_scope) — the alert is raised either way.

Lots we have not mirrored yet

If the lot has never reached us, PropPay pulls the source on the fly rather than rejecting the sale. If a pull is already running, the answer is 409 unit_not_synced — retry shortly.

When the lot is already held

Another live sale on the same lot is cancelled first and named in the response as superseded. On a multi-builder source “the lot” is your externalUnitId across every builder’s project, so a lot one builder sold and another has now sold again cancels the first builder’s sale, and that builder’s staff are told. That includes a sale with collected deposits or a signed PAD: its PAD is deactivated and its uncollected debits stop, money already collected or in process is left where it is, and the builder’s staff are told so someone settles it.

Before 4 October 2026 that case answered 409 supersede_blocked. That response is no longer sent.

POST /sales/:id/pad New

Turns banking collected in your app into a PAD agreement, unsigned, and emails each account holder a link to sign it here — and the purchaser their confirmation. Every buyer-facing email on this API leaves from this call.

Why unsigned

The agreement sits at draft, every deposit stays at awaiting_pad, and PropPay only ever debits against an active agreement. An account number pushed here cannot be charged until the person who owns it has signed for it, on our page, against a code sent to their own inbox.

Request

{
  "accountHolder": {
    "firstName": "Jane",
    "lastName":  "Doe",
    "address":   "123 Main Street",
    "city":      "Whitby",
    "province":  "ON",
    "phone":     "905-555-0134",
    "email":     "jane.doe@example.com"
  },
  "jointHolder": {
    "firstName": "John",
    "lastName":  "Doe",
    "email":     "john.doe@example.com"
  },
  "padCategory": "personal",
  "bank": {
    "bankName":    "RBC - Royal Bank of Canada",
    "institution": "003",
    "transit":     "12345",
    "account":     "1234567"
  },
  "voidCheque": "data:image/jpeg;base64,/9j/4AAQSk…"
}
FieldRequiredNotes
bank.institutionyes3-digit Payments Canada number, validated against the register. A leading zero lost to a JSON integer is restored.
bank.transityesExactly 5 digits.
bank.accountyes7 to 12 digits (non-digits are stripped first). Anything shorter or longer answers 400 invalid_payload, “bank.account must be 7 to 12 digits”. Encrypted at rest; never returned, never logged.
bank.bankNamenoDisplay only.
accountHolderpartialWhatever you omit is filled from the purchaser of record.
accountHolder.emailyesThe only address the signing link is sent to.
jointHoldernoIts presence makes this a joint PAD.
jointHolder.emailif jointRequired — see below.
padCategorynopersonal (default) or business.
voidChequenoBase64 data URL. PNG, JPEG, GIF or WebP, ≤ 3 MB decoded.

The account holder is not always the purchaser

Whoever is named here is who the agreement asks a signature from and where the link goes. Send the holder block whenever the person funding the deposits differs from the purchaser of record; omit it and we use the purchaser. Fields merge individually — anything you send wins, anything you omit falls back.

When the holder is not a purchaser on the sale, PropPay notifies the sales team with both names in front of them. Nothing is blocked; the third-party-funder case is ordinary and expected.

A joint holder needs their own email

Both holders must sign before anything can be debited, and that address is the only place the second link can go. A joint PAD with nowhere to send the second invitation can never activate, so it is refused at the door rather than issued stuck.

Response

201 Created

{
  "saleId":     "1f0c…",
  "padId":      "9ab3…",
  "termFileNo": "LX0017-D-1",
  "status":     "awaiting_signature",
  "signers": [
    { "role": "account_holder", "email": "j•••e@example.com", "signed": false },
    { "role": "joint_holder",   "email": "j•••n@example.com", "signed": false }
  ],
  "invited": ["account_holder", "joint_holder"]
}

Addresses come back masked. The bank account is never echoed.

Errors

CodeStatusMeaning
api_not_enabled403This project is not switched to API mode.
sale_not_found404Unknown externalTransactionId.
sale_cancelled409Sale is cancelled in PropPay LX.
invalid_payload400message names the field and the rule.
holder_name_required422No name from the payload or the purchaser.
holder_email_required422Nowhere to send the signing link.
joint_holder_email_required422Joint PAD with no second address.
pad_already_signed409An agreement on this sale has been signed.
no_deposit_schedule422The sale has no deposits, so the agreement would authorize nothing. Send the schedule, then re-push.
pad_generation_failed500Ours. Safe to retry.

Retries and corrections

  • Identical re-push — 200 idempotent: true, with the agreement already on file. Nothing is re-rendered and nobody is re-emailed. Comparison is on the values, not a payload hash, so reformatting (01234 against 1234) still matches. This holds only while that agreement is still live: if a schedule change has since retired it, the same push generates a fresh agreement and sends fresh invitations rather than handing back a retired one you could never escape.
  • Changed details, nothing signed yet — the draft is replaced and fresh invitations go out.
  • Changed details, something already signed — 409 pad_already_signed, nothing changes.

Why a signed agreement will not be replaced

Replacing it silently stops its deposits from being debitable while they still read as scheduled — a failure invisible until a pull date comes and goes with no money moved. A buyer changing banks after signing is real, and it is an operator action in the console, where the collection history is visible.

POST /sales/:id/documents New

Files a PDF against the sale — the APS, an amendment or a schedule.

It appears in the console's Documents modal under Schedules & Supporting Documents and, because the buyer portal reads the same list, in front of the purchaser alongside the PAD they are asked to sign.

Request

{
  "kind": "aps",
  "document": "data:application/pdf;base64,JVBERi0xLjQK…"
}
FieldRequiredNotes
kindnoaps (default), amendment or schedule.
documentyesBase64 PDF, with or without the data: prefix. Also accepted as data or pdf.

Size ceiling — 3 MB of decoded PDF

The whole request is capped at 4.5 MB by our host, and base64 adds a third. A generated APS fits comfortably; a scanned one may not. Over the limit you get a 400 that says so, not an opaque 413.

The same bytes pushed twice are one document — content-addressed by SHA-256, so an APS re-sent on every sync does not stack copies in front of the buyer.

Response

201 Created

{ "saleId", "docId", "kind", "label", "bytes" }

200 OK — those exact bytes are already on file

{ …, "idempotent": true }

Errors: 403 api_not_enabled, 404 sale_not_found, 400 invalid_payload (not a PDF, too large, or an unknown kind), 500 document_upload_failed.

POST /sales/:id/cancel Already live

Tells PropPay LX the sale was cancelled on your side. It does not cancel the sale here.

A cancellation in your system never cancels a sale in PropPay LX by itself. The sale is flagged, every debit and payout on it is paused from that moment, everyone on the project is told, and one of the builder’s team decides in the console whether it stands — keep it (its debits resume) or cancel it. The first answer is final.

A repeat call for a sale already flagged answers idempotent: true, with status: "kept" once the builder has chosen to keep it. Nothing is written back to you either way: the integration is one-way, from your system to PropPay LX. Before 4 October 2026 this endpoint cancelled the sale outright and answered 200 { id, cancelled }.

The flag is cleared only by that person’s answer. PropPay’s scheduled read of your sales list never lifts it, even if the list goes on showing the sale as live for a while. A sale kept against your cancellation stays kept: cancelling it again is a new question only after you have re-written the deal with a later transactionDate. A deal re-sold under the same id is a new sale (see POST /sales).

202 { id, status: "held_for_review" } 404 sale_not_found 422 sale_already_cancelled

POST /sales/:id/deposits Already live

Replaces the whole deposit schedule, which invalidates any agreement on the sale — signed or still an unsigned draft.

A new agreement must then be signed before anything can be collected. A draft is retired too, not just a signed one: signing a draft binds whatever deposits are live at that moment, which after a replacement are not the rows it printed.

200 { id, deposits, pad_required: true } 422 paid_deposit_conflict if any deposit is paid or in process.

What the buyer receives

Templates are per-project and editable by the builder under Project Settings → Emails. Both already existed — this API is not introducing new buyer-facing copy.

TriggerEmailTo
POST /salesNothing.—
POST /sales/:id/padPurchase confirmation, with the deposit schedulePurchaser of record
POST /sales/:id/padPAD ready for signing, with a role-scoped linkEach account holder, one each
All holders signedPAD signed & activatedBoth holders, plus the purchaser if they are a third person

A sale pushed on its own emails nobody. That is deliberate, and it is what makes POST /sales usable by itself: push the sale and the builder can see it and track its payments in PropPay, while the purchaser hears nothing until there is an agreement for them to sign. Both buyer-facing emails leave from /pad, where an agreement provably exists. The confirmation is deduped per sale, so re-issuing an agreement invites the holders again without re-announcing the purchase.

Limits

ConstraintValue
Request body4.5 MB host cap, 5 MB app cap
Void cheque3 MB decoded — PNG, JPEG, GIF, WebP
Document3 MB decoded — PDF only
Bank account7–12 digits
Signature window5 minutes
Signature nonce (v2)16–128 characters, single use per key
Rate limitingOnly failed authentication is counted