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

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.

ResponseWhat to check
401 invalid_signature
missing
The X-PropPay-Signature header was absent.
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 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.

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.

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. The buyer is emailed their purchase confirmation with the schedule.
  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, 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.

POST /sales Already live

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" }
  ]
}
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.
basePricenonumber | stringDollars, not cents. Omitted becomes 0.
totalPricenonumber | stringDollars. Omitted becomes 0.
closingDatenodate stringAs transactionDate.
purchasersnoarrayOmitted creates the sale with no purchaser — and so no confirmation email.
depositsnoarrayOmitted 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[]

FieldRequiredTypeNotes
idnostring | numberYour purchaser id, kept for later matching.
firstNamenostringProper-cased on the way in, so JANE stores as Jane.
lastNamenostringProper-cased.
emailnostringLower-cased. The first purchaser's address is the only place the purchase confirmation is sent.
phonenostringNormalised to E.164 — 905-555-0134 becomes +19055550134. Anything undialable stores as null rather than as junk.
sinnostringEncrypted 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[]

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 seeds the pull date, which an operator can move later.
indexnonumberOrdering. 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

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.
supersede_blocked409The lot is held by a sale with collected deposits or an active PAD. blockedBy carries the evidence.
sale_create_failed500Ours. 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.

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.

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.accountyesUp to 17 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.
pad_generation_failed500Ours. Safe to retry.

Retries and corrections

  • Identical re-push200 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.
  • Changed details, nothing signed yet — the draft is replaced and fresh invitations go out.
  • Changed details, something already signed409 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

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

POST /sales/:id/deposits Already live

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.

TriggerEmailTo
POST /salesPurchase 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

Nothing about the PAD is emailed at sale creation, because at that moment there is no agreement to sign.

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 account17 digits
Signature window5 minutes
Rate limitingOnly failed authentication is counted

PropPay LX · Partner push integration · Source of truth: docs/partner-push-api.md