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
Compute an HMAC-SHA256 over a Unix timestamp, a literal full stop, and the exact request body you are about to send. Send it alongside the token:
X-PropPay-Signature: t=1755624000,v1=<hex digest>
signed payload = "<t>." + rawRequestBody
key = your signing secret (shared out of band)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 |
The X-PropPay-Signature header was absent. |
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 body received — usually signing a re-serialized body, or the wrong secret. |
Signature verification is off until a signing secret is configured on our side, so you can integrate on the bearer token first and add signing when ready. Once the secret is configured it is mandatory — there is no per-request way to skip 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.
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.
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. The buyer is emailed their purchase confirmation with the schedule.
- 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, and every account holder is emailed a link to sign it.
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; marks the unit sold; emails the buyer their confirmation.
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",
"sin": "123456789"
}
],
"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. |
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 so no confirmation email. |
deposits | no | array | 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 an API-mode project does not run. Sending them is harmless and does nothing.
purchasers[]
| Field | Required | Type | Notes |
|---|---|---|---|
id | no | string | number | Your purchaser id, kept for later matching. |
firstName | no | string | Proper-cased on the way in, so JANE stores as Jane. |
lastName | no | string | Proper-cased. |
email | no | string | Lower-cased. The first purchaser's address is the only place the purchase confirmation is sent. |
phone | no | string | Normalised to E.164 — 905-555-0134 becomes +19055550134. Anything undialable stores as null rather than as junk. |
sin | no | string | Encrypted at rest. Never returned by any endpoint and never logged. |
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, and they are the fallback for whatever accountHolder block you omit at /pad.
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 seeds the pull date, which an operator can move later. |
index | no | number | 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.
Response
201 Created
{
"id": "4254815b…",
"pad_required": true,
"superseded": ["A00035-19"] // only when a live sale was cancelled
}
200 OK — this transaction id is already on file
{ "id", "pad_required", "idempotent": true }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. |
supersede_blocked | 409 | The lot is held by a sale with collected deposits or an active PAD. blockedBy carries the evidence. |
sale_create_failed | 500 | Ours. Safe to retry. |
Retries
Re-posting a known externalTransactionId returns 200 with idempotent: true.
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 superseded and named in the response. But if that sale has collected deposits or a signed PAD, nothing happens and the answer is 409 supersede_blocked, carrying the evidence.
Two real sales on one lot is a question about named people's money, so it goes to an operator rather than being resolved by a webhook.
Turns banking collected in your app into a PAD agreement, unsigned, and emails each account holder a link to sign it here.
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 | Up to 17 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. |
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. - 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.
Cancels the sale and everything downstream of it that has not already been paid.
Pending and scheduled deposits are cancelled; paid ones are left alone. Any PAD is deactivated, and the unit is handed back — unless another live sale still holds it.
200 { id, cancelled }
404 sale_not_found
422 sale_already_cancelled
Replaces the whole deposit schedule, which invalidates the active PAD.
A new agreement must then be signed before anything can be collected.
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 | 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 |
Nothing about the PAD is emailed at sale creation, because at that moment there is no agreement to sign.
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 | 17 digits |
| Signature window | 5 minutes |
| Rate limiting | Only failed authentication is counted |
PropPay LX · Partner push integration · Source of truth: docs/partner-push-api.md