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.
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)| Part | What it is |
|---|---|
t | Unix time in seconds. |
METHOD | The HTTP method in capitals: POST. |
path | The 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. |
nonce | A 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. |
rawRequestBody | The 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>." + rawRequestBodyv1 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.
| Response | What to check |
|---|---|
401 invalid_signaturemissing |
No X-PropPay-Signature header, or a v2 header without all of t, nonce and v2. |
401 invalid_signaturestale |
Your clock, not your key. The timestamp fell outside the 5-minute window. |
401 invalid_signaturemismatch |
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_signaturenonce must be… |
(v2) The nonce is too short, too long, or has a character outside A–Z a–z 0–9 _ -. |
401 invalid_signaturenonce has already been used |
(v2) This exact signature was already accepted once. Re-sign with a new nonce. |
401 invalid_signaturev1 … 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.
| Situation | Response |
|---|---|
| Project is not switched to API mode | 403 api_not_enabled |
| No PropPay project wired to that external id | 404 project_not_found |
Source feeds several builders, no companyId | 422 sale_unrouted |
companyId not mapped to a builder on that source | 422 sale_unrouted |
| Project is archived in PropPay LX | 409 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.
companyId | Builder | Use the key issued for |
|---|---|---|
114 | Zancor | the Zancor project (LX0017) |
4605 | Centurion | the Centurion project (LX0018) |
112 | Fernbrook | the 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.
| Field | Is | Legacy alias |
|---|---|---|
externalProjectId | Your project or community id | salefishProjectId |
externalTransactionId | Your sale id — the :id in every path below | salefishTransactionId |
externalUnitId | Your unit or lot id | salefishUnitId |
companyId | Your 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.
- POST /sales Sale created, unit marked sold, deposit schedule seeded. Nothing is emailed.
- POST /sales/:id/documents The APS is filed against the sale, visible to staff and to the buyer.
- 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.
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" }
]
}| Field | Required | Type | Notes |
|---|---|---|---|
externalTransactionId | yes | string | number | Your sale id. Becomes the :id in every later path. 400 invalid_payload without it. |
externalProjectId | yes | string | Your project or community id. |
externalUnitId | yes | string | number | Your lot or unit id. |
companyId | if multi | string | Only where the source feeds several builders. Omit it on a single-builder source. |
transactionDate | no | date string | Anything 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. |
basePrice | no | number | string | Dollars, not cents. Omitted becomes 0. |
totalPrice | no | number | string | Dollars. Omitted becomes 0. |
closingDate | no | date string | As transactionDate. |
purchasers | no | array | Omitted creates the sale with no purchaser, and nothing can be emailed at /pad later. |
deposits | no | array | Also 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[]
| Field | Required | Type | Also accepted as | Notes |
|---|---|---|---|---|
id | no | string | number | salefish_purchaser_id | Your purchaser id, kept for later matching. |
firstName | no | string | first_name | Proper-cased on the way in, so JANE stores as Jane. |
lastName | no | string | last_name | Proper-cased. |
email | no | string | — | Lower-cased. The first purchaser's address is the only place the purchase confirmation is later sent, at /pad. |
phone | no | string | cell | Normalised to E.164 — 905-555-0134 becomes +19055550134. phone is the primary; cell fills in when only it is sent. |
address | no | string | — | Street address. Proper-cased. |
city | no | string | — | Proper-cased. |
province | no | string | — | Code or full name. ON stays ON; ontario becomes Ontario. |
postalCode | no | string | postal_code | Normalised 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[]
| Field | Required | Type | Notes |
|---|---|---|---|
title | yes | string | The only required field in the array. Blank or missing returns 400 naming the index: deposit[2] missing required 'title'. Shown to the buyer verbatim. |
amount | no | number | string | Dollars. Omitted or unparsable becomes 0. |
percentage | no | number | string | Display only — it does not compute amount. A row with a percentage and no amount is a $0 deposit. |
dueDate | no | date string | Also accepted as due_date. Also seeds the pull date, which an operator can move later. |
index | no | number | Also 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
| Code | Status | Meaning |
|---|---|---|
invalid_payload | 400 | No externalTransactionId, or a deposit with no title. |
api_not_enabled | 403 | This project is not switched to API mode. |
project_scope | 403 | This key was issued for a different project. |
project_key_required | 403 | Platform token used on a project that has its own key. |
project_not_found | 404 | No PropPay project wired to that externalProjectId. |
sale_unrouted | 422 | Source feeds several builders; companyId missing or unmapped. |
project_archived | 409 | Project is archived in PropPay LX. |
unit_not_found | 404 | No such lot on that project, even after pulling your source. |
unit_not_synced | 409 | A pull was already running. Retry shortly. |
sale_create_failed | 500 | Ours. 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.
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…"
}| Field | Required | Notes |
|---|---|---|
bank.institution | yes | 3-digit Payments Canada number, validated against the register. A leading zero lost to a JSON integer is restored. |
bank.transit | yes | Exactly 5 digits. |
bank.account | yes | 7 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.bankName | no | Display only. |
accountHolder | partial | Whatever you omit is filled from the purchaser of record. |
accountHolder.email | yes | The only address the signing link is sent to. |
jointHolder | no | Its presence makes this a joint PAD. |
jointHolder.email | if joint | Required — see below. |
padCategory | no | personal (default) or business. |
voidCheque | no | Base64 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
| Code | Status | Meaning |
|---|---|---|
api_not_enabled | 403 | This project is not switched to API mode. |
sale_not_found | 404 | Unknown externalTransactionId. |
sale_cancelled | 409 | Sale is cancelled in PropPay LX. |
invalid_payload | 400 | message names the field and the rule. |
holder_name_required | 422 | No name from the payload or the purchaser. |
holder_email_required | 422 | Nowhere to send the signing link. |
joint_holder_email_required | 422 | Joint PAD with no second address. |
pad_already_signed | 409 | An agreement on this sale has been signed. |
no_deposit_schedule | 422 | The sale has no deposits, so the agreement would authorize nothing. Send the schedule, then re-push. |
pad_generation_failed | 500 | Ours. 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 (01234against1234) 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.
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…"
}| Field | Required | Notes |
|---|---|---|
kind | no | aps (default), amendment or schedule. |
document | yes | Base64 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.
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
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.
| Trigger | To | |
|---|---|---|
POST /sales | Nothing. | — |
POST /sales/:id/pad | Purchase confirmation, with the deposit schedule | Purchaser of record |
POST /sales/:id/pad | PAD ready for signing, with a role-scoped link | Each account holder, one each |
| All holders signed | PAD signed & activated | Both 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
| Constraint | Value |
|---|---|
| Request body | 4.5 MB host cap, 5 MB app cap |
| Void cheque | 3 MB decoded — PNG, JPEG, GIF, WebP |
| Document | 3 MB decoded — PDF only |
| Bank account | 7–12 digits |
| Signature window | 5 minutes |
| Signature nonce (v2) | 16–128 characters, single use per key |
| Rate limiting | Only failed authentication is counted |