PrivateAuto Partner API (1.x)

Download OpenAPI specification:

Deployed version: 1.1.2

Partner integration API for DealNow initiation, lead ingestion, partner-scoped read access, and real-time webhook notifications.

Authentication

All POST endpoints require a Bearer token in the Authorization header. The /data read endpoints (deals, offers, listings, webhook-deliveries, documents) use the same Bearer token. The GET /sell/{partnerId} endpoint additionally supports inline JWT auth via query params — see its own description.

The token must be a HS256, HS384, or HS512 JWT signed with your partner shared secret, which PrivateAuto provides during onboarding.

Generating the JWT

Your shared secret is delivered as a base64-encoded string — decode it to a UTF-8 string before signing. The JWT payload must include p (your partner identifier) and a short expiry (we recommend ≤ 5 minutes). Pick one of the following:

Node.js (npm install jsonwebtoken):

JWT=$(node -e '
  const jwt = require("jsonwebtoken");
  const secret = Buffer.from("YOUR_BASE64_SECRET", "base64").toString("utf-8");
  console.log(jwt.sign(
    { p: "YOUR_PARTNER_ID", iat: Math.floor(Date.now() / 1000) },
    secret,
    { algorithm: "HS256", expiresIn: "5m" }
  ));
')

Python (pip install pyjwt):

JWT=$(python3 -c '
import base64, jwt, time
secret = base64.b64decode("YOUR_BASE64_SECRET").decode("utf-8")
now = int(time.time())
print(jwt.encode(
    {"p": "YOUR_PARTNER_ID", "iat": now, "exp": now + 300},
    secret,
    algorithm="HS256",
))
')

CLI (jwt-cli, brew install mike-engel/jwt-cli/jwt-cli):

SECRET=$(printf '%s' 'YOUR_BASE64_SECRET' | base64 -d)
JWT=$(jwt encode \
  --alg HS256 \
  --secret "$SECRET" \
  --exp '+5m' \
  -P p=YOUR_PARTNER_ID)

Making an authenticated request

curl -X POST "https://partner-api.privateauto.com/sell/YOUR_PARTNER_ID" \
  -H "Authorization: Bearer $JWT" \
  -H "Content-Type: application/json" \
  -d '{ "vin": "1HGBH41JXMN109186", "amount": 18500 }'

Production host: https://partner-api.privateauto.com. Padev host: https://partner-api.padev.xyz.

Webhooks

PrivateAuto pushes real-time event notifications to your server whenever key actions happen on a deal, listing, offer, or user that originated through your partner integration.

Webhooks are configured per-partner during onboarding. You provide an HTTPS endpoint URL, a list of event types to subscribe to, and a webhook secret for signature verification.

Looking for the per-transaction event sequence? See the Event sequences by transaction type section below for the canonical event chain per deal shape (C2D / D2C / D2D / C2C, plus the partner-initiated /sell flow) and minimum-viable subscription sets.

Configuration

Setting Description
Endpoint URL Your public HTTPS URL. Private/internal IP addresses are rejected.
Webhook secret A shared secret used to sign every delivery. PrivateAuto provides this during onboarding.
Events The event types you want to receive. Supports wildcards (e.g. deal.*, *).

Delivery Format

Webhooks are delivered as POST requests with the following headers:

Header Description
Content-Type application/json
X-PA-Event Event type, e.g. deal.completed
X-PA-Timestamp ISO 8601 timestamp of when the event was emitted
X-PA-Signature HMAC-SHA256 signature (see Verifying Signatures)
X-PA-Partner Your partner ID
X-PA-Event-Id Stable per-event idempotency identifier (see Idempotency)
User-Agent PrivateAuto-Webhooks/1.0

Every payload also carries eventId and eventKey at the top level — see Idempotency.

Verifying Signatures

Every delivery includes an X-PA-Signature header of the form sha256={hex}. To verify:

  1. Extract X-PA-Timestamp from the request headers.
  2. Concatenate: {timestamp}.{raw_body}.
  3. Compute HMAC-SHA256 of that string using your webhook secret.
  4. Compare (hex-encoded) to the value after sha256= in X-PA-Signature.
const crypto = require('crypto');

function verifySignature(req, secret) {
  const timestamp = req.headers['x-pa-timestamp'];
  const received  = req.headers['x-pa-signature']; // "sha256=abc123..."
  const body      = req.rawBody;                   // raw bytes, not parsed JSON

  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${body}`)
    .digest('hex');

  return crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}

Always use the raw request body (before JSON parsing) for signature verification.

Idempotency

Every webhook payload carries two fields that let you safely dedupe duplicate deliveries:

Field Type Description
eventId string sha256 hex (64 chars). The canonical idempotency key. Stable across delivery retries and across our own internal replay (e.g. resume after a worker restart).
eventKey string Composite key the eventId was derived from. Informational only — see warning below.

The same eventId value is also delivered as the X-PA-Event-Id HTTP header.

Recommended: keep a cache of received eventIds for at least 24 hours and skip processing on a hit. 24 hours covers our 3-attempt retry window with margin.

Do not parse eventKey. Its internal structure (resume token, partner id, event name, emission index, separator) is an implementation detail and may change without an API version bump. It is provided for log correlation and support tickets only — only eventId is a stable contract.

eventId is included in the signed body, so it is automatically covered by the existing X-PA-Signature HMAC. No extra verification step is needed beyond the standard signature check.

Retry Behavior

If your endpoint returns a non-2xx status or times out (10-second limit), PrivateAuto retries with exponential backoff:

Attempt Delay
1 1 minute
2 5 minutes
3 15 minutes

After 3 failed attempts the event is moved to a dead-letter queue. Return 2xx as quickly as possible — process events asynchronously if needed.

Event Types

Subscribe to individual events or use wildcard namespaces:

Wildcard Matches
deal.* All deal events
listing.* All listing events
offer.* All offer events
user.* All user events
* All events

Deal Events

Event When it fires
deal.created A deal is initiated via the Partner API
deal.completed Deal fully completed (both parties confirmed, funds transferred)
deal.cancelled Deal cancelled by either party
deal.expired Deal expired without completion
deal.offer_accepted Both buyer and seller have confirmed deal terms
deal.buyer_joined Buyer has joined the deal
deal.seller_joined Seller has joined the deal
deal.buyer_ready Buyer has confirmed all steps and is ready to close
deal.seller_ready Seller has confirmed all steps and is ready to close
deal.co_seller_invited A co-seller invitation email is sent
deal.title_submitted Seller confirms the title-information step (ownership, registration, lien holder)
deal.title_approved Seller marks title attachment complete after uploading photos (seller-driven, not admin review)
deal.docs_submitted Buyer/seller submit closing documents
deal.docs_approved Closing documents are approved
deal.payment_initiated Payment transfer is initiated
deal.payment_acknowledged Seller acknowledges receipt of payment
deal.amount_changed Deal amount updated after creation
deal.negative_equity_identified Payoff amount exceeds deal amount
deal.negative_equity_funded Negative equity balance has been funded
deal.loan_payoff_docs Seller's loan payoff documents are uploaded
deal.loan_payoff_confirmed Loan payoff is manually confirmed
deal.poa_approved Power of Attorney document is fully signed
deal.bill_of_sale_approved Bill of Sale is fully signed by all required parties

Deal event payload:

Field Type Description
id string Deal document ID
guid string UUID for the deal (e.g. d30d5242-c880-4534-8c51-0032dde02093)
status string Current deal status
amount number Deal amount in dollars
buyerId string Buyer's user ID
sellerId string Seller's user ID
documents array Present on document-upload events only — see below

Deal payloads do not carry a vin. Fetch GET /data/deals/:partnerId/:dealId to obtain the deal's listingId, then GET /data/listings/:partnerId/:listingId for vehicle.vin and the full vehicle spec.

Document events (deal.title_submitted, deal.title_approved, deal.loan_payoff_docs, deal.poa_approved, deal.bill_of_sale_approved) also carry a documents array with short-lived signed S3 URLs:

Field Type Description
documents[].filename string Original filename
documents[].signedUrl string Pre-signed HTTPS URL — fetch within the expiry window
documents[].expiresAt string ISO 8601 timestamp when the signed URL stops working (4 hours after the event)

Signed URLs expire 4 hours after the event is emitted. Download and persist the file promptly — if you need the document later, request a fresh event or contact PrivateAuto support.

Example deal.title_submitted payload:

{
  "id": "64a1f2e3b5c8d90012345678",
  "eventId": "a2f1e9c4b7d8...",
  "eventKey": "82f1...:partner-acme:deal.title_submitted:0",
  "entityType": "deals",
  "status": "active",
  "guid": "d30d5242-c880-4534-8c51-0032dde02093",
  "amount": 18500,
  "buyerId": "64a1f2e3b5c8d90012340001",
  "sellerId": "64a1f2e3b5c8d90012340002",
  "documents": [
    {
      "filename": "title-front.jpg",
      "signedUrl": "https://pa-uploads.s3.us-east-2.amazonaws.com/...",
      "expiresAt": "2026-04-21T18:00:00.000Z"
    },
    {
      "filename": "title-back.jpg",
      "signedUrl": "https://pa-uploads.s3.us-east-2.amazonaws.com/...",
      "expiresAt": "2026-04-21T18:00:00.000Z"
    }
  ]
}

deal.poa_approved and deal.bill_of_sale_approved are deal-level events carried on the underlying document record. The payload looks different from other deal events:

Field Type Description
id string Document ID (not the deal ID)
entityType string documents for POA; platoforms_generated_documents for Bill of Sale
dealId string Deal this document belongs to — use this to join with your deal records
status string Document status (signed / completed)
docClassification string poa or bill-of-sale
documents array Signed S3 URLs for the completed document (same shape as above)

Example deal.poa_approved payload:

{
  "id": "64a1f2e3b5c8d90012349999",
  "entityType": "documents",
  "dealId": "64a1f2e3b5c8d90012345678",
  "status": "signed",
  "docClassification": "poa",
  "documents": [
    {
      "filename": "signed_poa.pdf",
      "signedUrl": "https://pa-uploads.s3.us-east-2.amazonaws.com/...",
      "expiresAt": "2026-04-21T18:00:00.000Z"
    }
  ]
}

Offer Events

Event When it fires
offer.created Buyer submits an offer
offer.accepted Seller accepts the offer
offer.rejected Seller rejects the offer
offer.cancelled Offer withdrawn by buyer

Offer event payload:

Field Type Description
id string Offer document ID
guid string UUID for the offer (matches the deal guid once a deal is created)
status string Current offer status
listingId string Associated listing ID
amount number Offer amount in dollars
buyerId string Buyer's user ID
sellerId string Seller's user ID

Listing Events

Event When it fires
listing.created A new listing is created
listing.updated Listing details edited
listing.published Listing goes live
listing.sold Vehicle marked sold
listing.deleted Listing deactivated or expired
listing.ownership_set Seller declared vehicle ownership type (owned / financed / leased)

Listing event payload:

Field Type Description
id string Listing document ID
status string Current listing status
vin string Vehicle Identification Number
year number Vehicle model year
make string Vehicle make
model string Vehicle model
trim string Vehicle trim level
price number Listing price in dollars
sellerId string Seller's user ID
ownershipType string owned, financed, or leased (present when ownership is declared)
vehicleHaveLien boolean Whether the vehicle has an active lien

User Events

Event When it fires
user.registered A user registers on your branded PrivateAuto site

User event payload:

Field Type Description
id string User document ID
email string User email address
firstName string User first name
lastName string User last name

Responding to Webhooks

  • Return any 2xx status to acknowledge receipt.
  • Return 2xx immediately and process the event asynchronously if your handler is slow.
  • Return 401 for signature failures so PrivateAuto can flag delivery issues.

UI Trigger Reference

Each partner event is emitted when a specific document change is observed on a Mongo change stream. The columns below name the underlying state change (authoritative — taken from lambda/trigger-distiller/src/reducers/) and the user-facing action that produces it. If your testing shows an event is not firing for an action listed here, file a ticket with the specific deal/listing ID — the field-change column is the contract.

Deal events

Event Mongo trigger User action
deal.created deals insert Buyer accepts an offer (DealNow flow); partner-api POST /sell/:partnerId creating a DealNow-pending offer that finalizes into a deal
deal.completed deals.statussold Final closing step (payment acknowledged + title released)
deal.cancelled deals.statusdealCancelled Either party cancels the deal from Deal Settings
deal.expired deals.statusdealExpired Background expiration job marks a stale deal expired
deal.offer_accepted deals.allPartiesConfirmed set truthy Both buyer and seller hit "Confirm Deal Terms" on the Offer step
deal.buyer_joined dealState.buyer.statusjoined Buyer signs in, accepts the deal invitation, and lands on the deal
deal.seller_joined dealState.seller.statusjoined Seller signs in and joins the deal
deal.buyer_ready dealState.buyer.statusreadyToClose Buyer marks "Ready to Close" after completing all required steps
deal.seller_ready dealState.seller.statusreadyToClose Seller marks "Ready to Close" after completing all required steps
deal.co_seller_invited seller.coSellerInviteSentAt set Seller sends a co-seller invitation email
deal.title_submitted seller.titleConfirmedDate set Seller confirms the title-information step (ownership, registration, lien holder); file uploads may or may not have happened yet
deal.title_approved titleAttachment.completedDate set Seller marks their uploaded title attachment "complete" (clicks Done in the title-attachment modal). Seller-driven, not admin review
deal.docs_submitted dealState.documentsStatuspending Buyer/seller sign all required closing documents, kicking off review
deal.docs_approved dealState.documentsStatuscomplete Internal/admin reviewer approves the closing document set
deal.payment_initiated dealState.closingStatusinitiated Buyer initiates the payment transfer (PrivateAuto Pay)
deal.payment_acknowledged dealState.closingStatusacknowledged Seller acknowledges receipt of payment
deal.amount_changed payment.privateAutoPay.amount updated on existing deal Either party (or admin) adjusts the deal amount after creation; not fired on insert
deal.negative_equity_identified negativeEquity set true Loan payoff acceptance where payoff > PA pay amount (flag stamped by spinwheel.service when manualPayoffInfo.statusACCEPTED)
deal.negative_equity_funded negativeEquityFunded set true Negative-equity loan payoff dispatch succeeds
deal.loan_payoff_docs creditreports.manualPayoffInfo.loanPayoffDocuments populated Seller uploads loan payoff documents on the Title step
deal.loan_payoff_confirmed creditreports.manualPayoffInfo.status → confirmed Internal reviewer confirms the loan payoff
deal.poa_approved documents (POA) becomes fully signed All required signers complete Power of Attorney signing
deal.bill_of_sale_approved platoforms_generated_documents (BoS) marked complete Bill of Sale fully signed by all required parties

Offer events

Event Mongo trigger User action
offer.created offers insert Buyer submits an offer on a listing; partner-api POST /sell/:partnerId creates a DealNow offer
offer.accepted offers.statusaccepted Seller accepts an offer from the Listing/Offers screen
offer.rejected offers.statusdeclined Seller declines an offer (internal status declined → external rejected)
offer.cancelled offers.statusdeleted Buyer withdraws an offer (internal status deleted → external cancelled)

Listing events

Event Mongo trigger User action
listing.created seller_listings insert Seller saves a new listing (initial document insert)
listing.updated seller_listings update where no more specific tag matches Seller edits a listing field that doesn't already trigger a more specific event (i.e. not a status transition, ownership-set, or cash-offer). One Mongo update produces at most one listing event — listing.updated is the catch-all for everything else.
listing.published statuslive Seller hits "Publish" on a draft listing
listing.sold statussold Listing marked sold (manual or via accepted DealNow flow)
listing.deleted statusdeactivated or expired, OR soft-delete via deleted: true, OR physical delete (no fullDocument — not delivered) Seller deactivates a listing, expiration job marks it expired, or admin tools soft-delete the document
listing.ownership_set ownershipInfo.ownershipType set/changed Seller answers the "Do you own this vehicle outright, finance it, or lease it?" question on listing setup. Also fires on insert when ownership is included in the initial document.

User events

Event Mongo trigger User action
user.registered users insert with appRegisteredFrom = <partnerId> A user completes registration on your branded PrivateAuto experience

Notes for partners diagnosing missing events

  • Webhook routing is gated on either appSource (the partner who originated the entity) or organization linkage (per the partner's webhookScope — see admin-api). If neither match holds for a deal/listing/offer, no event will be queued.
  • Each partner has an event subscription list (default: ["*"]). Events absent from that list are silently dropped before delivery.
  • A change-stream event with no fullDocument (i.e. a physical delete) does not produce partner webhooks. Soft-deletes (deleted: true) do.

Any deal/listing/document URL that PrivateAuto generates for a partner — whether returned by the sell endpoint or embedded in a future webhook payload — uses the app. subdomain (https://app.<host>/...), never the apex. The host is selected per-partner via the linkPlatform config on the partner record:

linkPlatform Host
dealnow (default) https://app.${dealNowDomain}
pa-app https://app.${appDomain}

Apex domains (https://${dealNowDomain}, https://${appDomain}) are reserved for marketing pages and are not partner deep-link targets.

Webhook → /data endpoint cross-reference

Webhook payloads are intentionally compact — they carry the IDs, status fields, and a small set of stable identifiers, but not the full document. To pull the rich shape of a deal, offer, listing, or document, follow the IDs in the payload to a GET /data/... endpoint. The response shapes are stable, authoritative, and described in data-endpoints.md.

Use the webhook as the signal, the /data endpoint as the source of truth.

Event family Payload ID field Fetch full record from
deal.* id (deal _id) GET /data/deals/:partnerId/:dealIdPartnerDealResponse
offer.* id (offer _id); also listingId GET /data/offers/:partnerId/:offerId; GET /data/listings/:partnerId/:listingId
listing.* id (listing _id) GET /data/listings/:partnerId/:listingIdPartnerListingResponse
user.registered id (user _id) Not exposed via /data. The webhook payload is the only delivery — capture email/firstName/lastName from the body.
deal.*_docs, deal.title_*, deal.poa_approved, deal.bill_of_sale_approved, document.* dealId (plus optional documentId) GET /data/deals/:partnerId/:dealId/documents (re-issues fresh 4-hour signed URLs); single-doc fetches via GET /data/documents/:partnerId/:documentId or GET /data/generated-documents/:partnerId/:documentId
Any delivery — replay/inspect eventId (header X-PA-Event-Id) GET /data/webhook-deliveries/:partnerId/:logId returns the dispatched body + attempt metadata. Use GET /data/webhook-deliveries/:partnerId (filterable) to find the logId.

Per-event field mapping (which payload field unlocks which /data field on the corresponding response DTO):

deal.* payload → PartnerDealResponse

Payload field PartnerDealResponse field
id id (use as :dealId)
guid guid
status status
amount payment.privateAutoPay.amount
buyerId buyer.id
sellerId seller.id
(none — not on the payload) listingId, dealState.*, seller.organizationId, buyer.organizationId, payment.*, titleAttachment.*, timestamps. Fetch via getDeal to get all.

offer.* payload → PartnerOfferResponse

Payload field PartnerOfferResponse field
id id (use as :offerId)
guid guid
status status
amount price
listingId listing (use as :listingId on getListing)
buyerId offeredBy
sellerId offeredTo

listing.* payload → PartnerListingResponse

Payload field PartnerListingResponse field
id id (use as :listingId)
status status
vin vehicle.vin
year vehicle.year
make vehicle.make
model vehicle.model
trim vehicle.trim
price price
sellerId sellerId
ownershipType ownershipInfo.ownershipType
vehicleHaveLien ownershipInfo.vehicleHaveLien

Document events payload → PartnerDealDocumentsResponse / detail DTOs

Payload field Where to follow it
dealId GET /data/deals/:partnerId/:dealId/documents → full doc set (title + documents[] + generatedDocuments[]) with fresh signed URLs
documentId (when present) GET /data/documents/:partnerId/:documentId (documents collection) or GET /data/generated-documents/:partnerId/:documentId (platoforms_generated_documents) — choose by entityType on the payload
docClassification Filter on the /data response — same vocabulary (bill-of-sale, poa, etc.)
documents[].signedUrl Short-lived (4h). When expired, re-fetch via the /data endpoint above to receive freshly-signed URLs.

Event sequences by transaction type

How the events above sequence depends on which side the partner sits on and how the deal originated. The rest of this section walks each shape end-to-end.

Workflow type — how PrivateAuto classifies a deal

Every deal record carries an internal workflow_type field derived from the org membership of each party:

seller.organizationId buyer.organizationId workflow_type Plain-English shape
C2C Consumer sells to consumer (no dealer involved)
C2D Dealer acquires from a consumer (dealer-acquisition)
D2C Dealer sells to a consumer (retail)
D2D Wholesale between two dealers

Partners always show up on the side that has organizationId === partner.organizationId. Deals where the partner org is on neither side don't fire any partner webhooks for that partner (org-routed scope) or rely on appSource (appSource-routed scope) — see the routing notes at the end of this page.

1. C2D — partner is the buyer (dealer-acquisition)

The most common partner shape: a dealer integration acquiring a vehicle from a consumer. Two sub-flows depending on entry point:

1a. C2D — partner-initiated via POST /sell/:partnerId

The partner kicks off the flow from their own UI. The seller receives a deep-link to confirm ownership and the rest of the steps happen via the consumer-facing app (or via further partner-api calls).

                    HTTP        Event delivered                Notes
─────────────────────────────  ────────────────────────────  ──────────────────────────────────
POST /sell/:partnerId          listing.created               Stub listing created for the VIN
                               offer.created                 dealNowPending offer with the
                                                              partner-org user pre-bound on
                                                              the buyer side (per PA-4489)
─────────────────────────────  ────────────────────────────  ──────────────────────────────────
seller follows seller-link     offer.accepted                dealer + consumer both bound
(consumer confirms ownership)  deal.created                  deal record materializes
─────────────────────────────  ────────────────────────────  ──────────────────────────────────
docs round (per signer)        document.signature_added      one per manualSignatures slot
last signer signs              document.completed            terminal per-doc event
                               deal.bill_of_sale_approved    (legacy, deprecating; same event
                                                              filtered to bill-of-sale only)
─────────────────────────────  ────────────────────────────  ──────────────────────────────────
admin reviews & approves docs  deal.docs_approved            dealState.documentsStatus →
                                                              complete
─────────────────────────────  ────────────────────────────  ──────────────────────────────────
POST /api/deals/start-transfer deal.payment_initiated        buyer (dealer) clicks "send
                                                              funds"
─────────────────────────────  ────────────────────────────  ──────────────────────────────────
POST /api/deals/confirm-       deal.payment_acknowledged     seller (consumer) confirms
  payment                      deal.completed                receipt; updateTransferDates
                                                              auto-flips deal.status to 'sold'

Minimum event subscription to track this flow: deal.created, document.completed, deal.payment_initiated, deal.payment_acknowledged, deal.completed. Adding deal.docs_approved lets you mirror PA's "all docs reviewed" state for buyer-side UI.

1b. C2D — consumer-initiated (in-app)

Consumer lists the vehicle on PrivateAuto, the dealer (your partner-org user) offers via the consumer-facing app, and both parties accept. Webhooks for partners on the buyer side:

listing.created          consumer creates the listing (only if appSource match)
listing.published        consumer publishes
offer.created            dealer submits an offer (offer.appSource matches partner)
offer.accepted           consumer accepts
deal.created             materializes from accepted offer
{ same docs / payment / completion chain as 1a }

Identical from offer.accepted onward.

2. D2C — partner is the seller

Mirror of C2D with the partner on the seller side. Webhooks the partner cares about:

listing.created              partner (or partner-API) creates the listing
listing.published            listing goes live
listing.ownership_set OR     ownershipInfo.ownershipType set by admin/internal
  listing.ownership_submitted ownershipInfo.ownershipType set by partner user
─────────────────────────────────────────────────────────────────────────────
offer.created                consumer (buyer) offers
offer.accepted               partner-side acceptance (or counter-offer chain)
deal.created                 deal record materializes
{ docs round — partner signs Bill of Sale, POA, etc. as the seller }
deal.docs_approved
deal.payment_initiated       consumer initiates payment
deal.payment_acknowledged    partner confirms receipt
deal.completed
listing.sold                 listing's status flips to 'sold' after deal completes

3. D2D — wholesale between two partner organizations

Both sides are org users. Each subscribed partner receives webhooks where their org appears on either side. The event set is the same as D2C / C2D but the partner sees BOTH a listing.published (seller side) and an offer.created (buyer side) for the same deal if they're routing both partners. Dedupe via the deal's guid.

4. C2C — pure consumer-to-consumer

Partner webhooks only fire if the offer or deal carries appSource = <partnerId> (i.e. the partner branded the originating user experience even though neither party joined the partner's org). Subscribe with webhookScope: "appSource" in your partner config; org-routed partners receive nothing for C2C.

Document signing — per-signer detail

The Bill of Sale and POA (and any other generated document with manualSignatures) drive two new events per document (and the legacy classifier-specific event during the deprecation window):

manualSignatures[0].signedAt set    document.signature_added (slot=0, role=seller)
manualSignatures[1].signedAt set    document.signature_added (slot=1, role=buyer)
                                    document.completed       (all signers complete)
                                    deal.bill_of_sale_approved  (legacy, BoS only)
                                    deal.poa_approved           (legacy, POA only)

Each document.signature_added payload carries:

{
  "id": "<documentId>",
  "dealId": "<dealId>",
  "docClassification": "bill-of-sale" | "poa" | "addendum" |,
  "signers": [{ "signerRole": "seller" | "buyer", "signedAt": "<ISO date>" }]
}

Integrators discriminate document types by docClassification rather than by event name — document.completed arrives once per document regardless of which classification it carries. The legacy deal.bill_of_sale_approved and deal.poa_approved events keep firing during the deprecation window for backward compatibility.

Negative-equity events

When the consumer side's loan payoff exceeds the offered price:

User vs admin path Event Triggered by
User-side deal.negative_equity_reported Consumer-facing flow updates deal.payment.privateAutoPay.amount and crosses the payoff threshold
Admin/service-side deal.negative_equity_identified Admin SpinwheelService stamps negativeEquity: true

Both events carry negativeEquity: true on the payload. The event name is the discriminator — no source field. Once the negative-equity payoff is dispatched, deal.negative_equity_funded fires.

Loan payoff document flow

Specific to deals where the consumer-side seller has an outstanding loan on the vehicle:

seller uploads loan payoff doc  →  deal.loan_payoff_docs       (only the first upload)
internal reviewer confirms     →  deal.loan_payoff_confirmed

The deal.loan_payoff_docs event references the underlying credit_reports document by id (not the deal id) — fetch the full deal via getDealDocuments to see the attached doc.

Documents generated per transaction type

What appears on a deal — and therefore what signatures and document events you should expect — varies by who's on each side. PrivateAuto generates a different document set depending on whether the parties are consumers, dealers, or a mix. The table below lists the docs you should plan to see; each row is a separate row in deal.documents[] (collection: documents or platoforms_generated_documents) with its own signers and its own document.signature_added / document.completed / deal.docs_approved chain.

Document C2C C2D D2C D2D Notes
Title attachment (front / back photos) Seller confirms title info → deal.title_submitted; seller uploads photos + marks attachment complete → deal.title_approved. Both are seller-driven
Bill of Sale (docClassification: 'bill-of-sale') Always generated; both parties sign — fires document.signature_added (×2) + document.completed (+ legacy deal.bill_of_sale_approved)
Power of Attorney (docClassification: 'poa') Generated when a dealer is on either side (the dealer signs as buyer-rep / seller-rep). Fires document.signature_added per signer + document.completed (+ legacy deal.poa_approved)
Loan payoff documents seller-lien only seller-lien only Only when the consumer-side seller's vehicle has an outstanding loan. Stored in credit_reports.manualPayoffInfo.loanPayoffDocuments; surfaces via deal.loan_payoff_docs + deal.loan_payoff_confirmed
Self-inspection photos optional optional Buyer-requested third-party inspection; not signed via PrivateAuto, stored as attached photos. No webhook today
State-specific paperwork (odometer, lien-release, title application) varies varies varies varies Generated as needed based on deal.state (US state code). Not surfaced as distinct events; tracked as part of dealState.documentsStatus

How docs are linked to a deal

  • deal.documents[] is an array of references to the documents collection (POA + state paperwork + addendum docs).
  • platoforms_generated_documents.*.sharedDeals[] references back to the deal(s) the BoS lives on (the BoS can be re-used across co-seller scenarios).
  • Title front/back live on deal.titleAttachment (not in documents collection).
  • Loan-payoff docs live in credit_reports.manualPayoffInfo.loanPayoffDocuments (not in documents collection).

When you call GET /data/deals/:partnerId/:dealId/documents, the response merges all of the above into a single documents[] array with fresh 4-hour signed S3 URLs. That's the canonical "what's on this deal" endpoint — partners don't need to know the storage split.

Co-seller multiplier

When deal.coSeller is populated, the Bill of Sale and POA each pick up an extra signer slot for the co-seller. The signature events still fire once per slot — document.signature_added fires three times for a 3-signer document (seller + co-seller + buyer), and document.completed fires once when all three have signed. Watch the signers[] array on the payload to track who has signed.

What's NOT generated

  • No purchase agreement separate from the BoS. PrivateAuto's BoS template serves as the purchase agreement; states that distinguish the two are handled inside the BoS body.
  • No financing addendum. Loan information lives on credit_reports, not in a separate signed doc.
  • No retail-style "we-owe" or addendum docs. These are dealer-CRM concerns, not PrivateAuto deal state.

Routing — webhookScope summary

How partner.webhookScope affects which of the above events you receive:

webhookScope Listings Deals & offers Documents Users
appSource Only if listing.appSource === partnerId Only if offer.appSource === partnerId or deal.appSource === partnerId Inherits from linked deal's appSource user.registered only when user.appRegisteredFrom === partnerId
organization Listing owner is in partner.organizationId Either side of the deal is in partner.organizationId Linked deal's seller or buyer is in partner.organizationId User's userDetails.organizationId === partner.organizationId
both (default) Either of the above matches Either of the above matches Either of the above matches Either of the above matches

If listings / documents / user.registered events aren't firing on webhookScope=organization, the cause is almost always that the source user / document has no org link populated. See the gotcha box in webhooks.md for the conditional-routing matrix.

What you should write in your integration to be future-proof

  1. Subscribe to events by name, not order. Multiple events can land in the same Mongo change-stream tick (e.g. offer.accepted + deal.created). Process by name; treat sequence as informational.
  2. Dedupe on eventId. The same partner webhook will retry on transient delivery failures; eventId is stable per (resume token, partner, event, emission index) so you can safely treat it as an idempotency key.
  3. Always fetch full state from /data/... endpoints if you need anything beyond what the webhook payload exposes — payloads are intentionally compact and stable; full DTOs are the place to read.
  4. Plan for the deprecation window. During PA's deprecation of deal.bill_of_sale_approved / deal.poa_approved in favor of document.completed + docClassification, you'll receive both. Subscribing only to document.* is forward-compatible; subscribing only to the legacy names will go silent at some point.
  5. Treat absent events as legitimately absent, not as failures. The dealer-acquisition flow doesn't pass through offer.accepted in some lifecycle shapes; the user-driven path skips MARK_SOLD because the seller's confirm-payment call writes status='sold' directly. The event catalog tells you what can fire, not what must.

sell

DealNow deal initiation — redirect a prospect into a PrivateAuto deal or create one programmatically

Initiate DealNow via GET (URL params or JWT)

Initiate a DealNow transaction via browser redirect. The user is redirected to their buyer link (or a redirectUrl you specify).

Auth: Inline — JWT (Formats 1/2) or HMAC-signed short-key query (Format 3). No Authorization header needed; all three formats authenticate against the partner shared secret.

Query Parameter Formats

Format 1 — Explicit JWT

GET /sell/mypartner?jwt=<token>

Format 2 — VIN param is a JWT

GET /sell/mypartner?v=<token>

If the v param contains dots, it is treated as a JWT. The JWT payload may use either full field names or the short keys.

Format 3 — Signed short key query params

GET /sell/mypartner?v=1HGBH41JXMN109186&y=2021&m=Honda&o=Civic&a=18500&be=buyer@example.com&r=https://yoursite.com/thanks&ts=<epochSec>&sig=<hex>

Format 3 requires ts (current epoch seconds, ±5 min skew allowed) and sig — a hex-encoded HMAC-SHA256 of the canonical query string, signed with the partner's shared secret (the same secret used for JWT signing).

Canonical string: every query param except sig, sorted alphabetically by key, URL-encoded k=v joined by &.

# Node example
node -e '
  const crypto = require("crypto");
  const secret = Buffer.from("YOUR_BASE64_SECRET", "base64").toString("utf-8");
  const params = { v: "1HGBH41JXMN109186", y: 2021, m: "Honda", o: "Civic",
                   a: 18500, be: "buyer@example.com", ts: Math.floor(Date.now()/1000) };
  const canonical = Object.keys(params).sort()
    .map(k => encodeURIComponent(k) + "=" + encodeURIComponent(params[k]))
    .join("&");
  const sig = crypto.createHmac("sha256", secret).update(canonical).digest("hex");
  console.log(canonical + "&sig=" + sig);
'

Short Key Reference

Short Full Field Type
v vin string
y year number
m make string
o model string
t trim string
a amount number
bn buyerName string
be buyerEmail string
bl buyerSendLink boolean
sn sellerName string
se sellerEmail string
sl sellerSendLink boolean
r redirectUrl string
ro role buyer or seller (default: buyer)

Redirect Behavior

  • If redirectUrl is provided, the user is redirected there after the deal is created.
  • If no redirectUrl is provided, the user is redirected to the link for the party specified by role (default: buyer).
  • Use role=seller when the initiating user is the vehicle seller rather than the buyer.
path Parameters
partnerId
required
string

Your partner identifier, provided during onboarding (e.g. mypartner)

query Parameters
v
any

VIN or JWT token (Format 2/3)

jwt
any

Explicit JWT token (Format 1)

Responses

Initiate DealNow via POST (JSON body)

Initiate a DealNow transaction. Returns buyer and seller deep links.

Requires a HS256, HS384, or HS512 JWT Bearer token signed with your partner shared secret.

If the vehicle is not already listed on PrivateAuto, year, make, and model are required in addition to vin.

Note: redirectUrl and role are GET-only parameters and are not applicable to this endpoint.

Generating the JWT

Your shared secret is delivered as a base64-encoded string — decode it to a UTF-8 string before signing. Pick one of the following:

Node.js (npm install jsonwebtoken):

JWT=$(node -e '
  const jwt = require("jsonwebtoken");
  const secret = Buffer.from("YOUR_BASE64_SECRET", "base64").toString("utf-8");
  console.log(jwt.sign(
    { p: "YOUR_PARTNER_ID", iat: Math.floor(Date.now() / 1000) },
    secret,
    { algorithm: "HS256", expiresIn: "5m" }
  ));
')

Python (pip install pyjwt):

JWT=$(python3 -c '
import base64, jwt, time
secret = base64.b64decode("YOUR_BASE64_SECRET").decode("utf-8")
now = int(time.time())
print(jwt.encode(
    {"p": "YOUR_PARTNER_ID", "iat": now, "exp": now + 300},
    secret,
    algorithm="HS256",
))
')

CLI (jwt-cli, brew install mike-engel/jwt-cli/jwt-cli):

SECRET=$(printf '%s' 'YOUR_BASE64_SECRET' | base64 -d)
JWT=$(jwt encode \
  --alg HS256 \
  --secret "$SECRET" \
  --exp '+5m' \
  -P p=YOUR_PARTNER_ID)

Making the request

curl -X POST "https://partner-api.padev.xyz/sell/YOUR_PARTNER_ID" \
  -H "Authorization: Bearer $JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "vin": "1HGBH41JXMN109186",
    "year": 2021,
    "make": "Honda",
    "model": "Accord",
    "trim": "EX-L",
    "amount": 18500,
    "buyerName": "Jane Buyer",
    "buyerEmail": "jane@example.com",
    "buyerSendLink": true,
    "sellerName": "John Seller",
    "sellerEmail": "john@example.com",
    "sellerSendLink": true
  }'

Production host: https://partner-api.privateauto.com.

Authorizations:
bearer
path Parameters
partnerId
required
string

Your partner identifier, provided during onboarding (e.g. mypartner)

Request Body schema: application/json
required
vin
required
string

Vehicle Identification Number (17 characters)

buyerName
string

Buyer full name

buyerEmail
string

Buyer email address. Convention: this field describes the OTHER party — the side being invited via the deal link. If your integration is on the buyer side, do NOT pass your own email here; pass the seller's email in sellerEmail instead. The supplied email determines which dealNowType is inferred for the resulting offer.

buyerSendLink
boolean

If true, emails the buyer their deal link (requires buyerEmail)

sellerName
string

Seller full name

sellerEmail
string

Seller email address. Same convention as buyerEmail: this is the OTHER party from the partner. If your integration is on the seller side, do NOT pass your own email here.

sellerSendLink
boolean

If true, emails the seller their deal link (requires sellerEmail)

year
number

Vehicle model year. Required if vehicle is not already listed.

make
string

Vehicle make. Required if vehicle is not already listed.

model
string

Vehicle model. Required if vehicle is not already listed.

trim
string

Vehicle trim level

amount
number

Deal amount in dollars

Responses

Request samples

Content type
application/json
{
  • "vin": "1HGBH41JXMN109186",
  • "buyerName": "Jane Smith",
  • "buyerEmail": "buyer@example.com",
  • "buyerSendLink": true,
  • "sellerName": "John Doe",
  • "sellerEmail": "seller@example.com",
  • "sellerSendLink": true,
  • "year": 2021,
  • "make": "Honda",
  • "model": "Civic",
  • "trim": "EX",
  • "amount": 18500
}

Response samples

Content type
application/json
{}

leads

Lead ingestion — submit and look up leads for dealer organizations on your partner allowlist. POST is idempotent via callerReferenceId and subject to per-partner rate limits.

Submit a lead

Submit a lead for a dealer organization.

Requires a HS256, HS384, or HS512 JWT Bearer token signed with your partner shared secret.

Idempotent Submission

The callerReferenceId is your own identifier for this lead (e.g. your CRM record ID). Submitting the same callerReferenceId twice returns 409 Conflict with the existing lead ID, so you can safely retry without creating duplicates.

Organization Authorization

Your partner account has an allowlist of organization IDs you can submit leads to. Submitting to an org not on your allowlist returns 403 Forbidden.

Request Example

{
  "callerReferenceId": "LDR-98234",
  "organizationId": "663a1f...",
  "leadCode": "CONQUEST",
  "contact": {
    "name": "Jane Smith",
    "email": "jane@example.com",
    "phone": "555-867-5309",
    "message": "Interested in trading in my truck"
  },
  "vehicle": {
    "vin": "1HGBH41JXMN109186",
    "year": 2019,
    "make": "Honda",
    "model": "Accord",
    "trim": "Sport",
    "mileage": 42000
  }
}
Authorizations:
bearer
path Parameters
partnerId
required
string

Your partner identifier

Request Body schema: application/json
required
callerReferenceId
required
string

Your own reference ID for this lead — used for idempotent dedup. Resubmitting the same value returns 409 with the existing lead ID.

organizationId
required
string

Target dealer organization ID. Must be on your partner allowlist.

leadCode
string

Lead code tag for categorization (e.g. CONQUEST, SERVICE, TRADE_IN)

object

Prospect contact information

object

Vehicle of interest

listingId
string

Link to an existing PrivateAuto listing ID

Responses

Request samples

Content type
application/json
{
  • "callerReferenceId": "LDR-98234",
  • "organizationId": "663a1f2b4e8c9a001234abcd",
  • "leadCode": "CONQUEST",
  • "contact": {
    },
  • "vehicle": {
    },
  • "listingId": "663b2f3c5e9d0b002345bcde"
}

Response samples

Content type
application/json
{
  • "leadId": "663c3f4d6fae0c003456cdef",
  • "callerReferenceId": "LDR-98234",
  • "status": "new",
  • "createdAt": "2026-04-16T14:22:00.000Z"
}

Look up a lead by callerReferenceId

Look up a previously submitted lead using your own reference ID.

Requires a HS256, HS384, or HS512 JWT Bearer token signed with your partner shared secret.

Returns the current lead status and metadata. Use this to check whether a lead was already submitted (e.g. before retrying) or to poll for status updates.

Authorizations:
bearer
path Parameters
partnerId
required
string

Your partner identifier

query Parameters
callerReferenceId
required
string non-empty

Your reference ID for the lead — the same value you passed on submission

Responses

Response samples

Content type
application/json
{
  • "leadId": "663c3f4d6fae0c003456cdef",
  • "callerReferenceId": "LDR-98234",
  • "status": "new",
  • "createdAt": "2026-04-16T14:22:00.000Z"
}

data

List deals where the partner org is buyer or seller

Authorizations:
bearer
path Parameters
partnerId
required
string

Your partner identifier (e.g. mypartner)

query Parameters
_page
number
Default: 0

Zero-based page index

_limit
number
Default: 50

Page size (max 100)

status
string

Filter by status

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "_meta": {
    }
}

Get a single deal scoped to the partner org

Authorizations:
bearer
path Parameters
partnerId
required
string

Your partner identifier (e.g. mypartner)

dealId
required
string

Deal Mongo _id

Responses

Response samples

Content type
application/json
{
  • "id": "6a0243d9a30d4d0026d00e7c",
  • "guid": "653a905c-73b2-4e4b-bdf6-0e25b1e71363",
  • "status": "active",
  • "closingStatus": "none",
  • "closingFeePayer": "buyer",
  • "state": "string",
  • "odometer": "string",
  • "appSource": "string",
  • "listingId": "string",
  • "offerId": "string",
  • "currentOfferStatus": "pending",
  • "documentsStatus": "empty",
  • "negativeEquity": true,
  • "negativeEquityFunded": true,
  • "amount": 0,
  • "currency": "USD",
  • "buyer": {
    },
  • "seller": {
    },
  • "payment": {
    },
  • "documents": [
    ],
  • "dealState": {
    },
  • "titleApprovedAt": "2019-08-24T14:15:22Z",
  • "ownershipType": "owned",
  • "payoffType": "LOAN",
  • "loanPayoffDocs": [
    ],
  • "loanPayoffConfirmed": true,
  • "statusHistory": [
    ],
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z"
}

List offers where the partner org is offering or being offered to

Authorizations:
bearer
path Parameters
partnerId
required
string

Your partner identifier (e.g. mypartner)

query Parameters
_page
number
Default: 0

Zero-based page index

_limit
number
Default: 50

Page size (max 100)

status
string

Filter by status

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "_meta": {
    }
}

Get a single offer scoped to the partner org

Authorizations:
bearer
path Parameters
partnerId
required
string

Your partner identifier (e.g. mypartner)

offerId
required
string

Offer Mongo _id

Responses

Response samples

Content type
application/json
{
  • "id": "6a0243d8a30d4d0026d00e7a",
  • "guid": "653a905c-73b2-4e4b-bdf6-0e25b1e71363",
  • "status": "pending",
  • "price": 0,
  • "currency": "USD",
  • "listingId": "string",
  • "offeredByUserId": "string",
  • "offeredByOrganizationId": "string",
  • "offeredToUserId": "string",
  • "offeredToOrganizationId": "string",
  • "externalId": "string",
  • "buyerExternalId": "string",
  • "sellerExternalId": "string",
  • "appSource": "string",
  • "dealNowExpiration": "2019-08-24T14:15:22Z",
  • "titleDeliveryMethod": "InPerson",
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z"
}

List listings owned by the partner org

Authorizations:
bearer
path Parameters
partnerId
required
string

Your partner identifier (e.g. mypartner)

query Parameters
_page
number
Default: 0

Zero-based page index

_limit
number
Default: 50

Page size (max 100)

status
string

Filter by status

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "_meta": {
    }
}

Get a single listing scoped to the partner org

Authorizations:
bearer
path Parameters
partnerId
required
string

Your partner identifier (e.g. mypartner)

listingId
required
string

Listing Mongo _id

Responses

Response samples

Content type
application/json
{
  • "id": "6a024361a30d4d0026d00db2",
  • "listingNo": "string",
  • "slug": "string",
  • "title": "string",
  • "status": "pending",
  • "sellerId": "string",
  • "organizationId": "string",
  • "price": 0,
  • "vehicle": {
    },
  • "ownershipInfo": {
    },
  • "condition": "new",
  • "appSource": "string",
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z"
}

List webhook delivery attempts for this partner

Authorizations:
bearer
path Parameters
partnerId
required
string

Your partner identifier (e.g. mypartner)

query Parameters
_page
number
Default: 0

Zero-based page index

_limit
number
Default: 50

Page size (max 100)

status
string
Enum: "delivered" "failed" "skipped"

Filter by delivery status

event
string

Filter by event name (e.g. deal.created)

dateFrom
string

ISO 8601 — only deliveries attempted at or after this time

dateTo
string

ISO 8601 — only deliveries attempted at or before this time

Responses

Response samples

Content type
application/json
{
  • "data": [
    ],
  • "_meta": {
    }
}

Get a single webhook delivery attempt

Authorizations:
bearer
path Parameters
partnerId
required
string

Your partner identifier (e.g. mypartner)

logId
required
string

Outbound delivery log Mongo _id

Responses

Response samples

Content type
application/json
{
  • "id": "string",
  • "partnerId": "string",
  • "event": "deal.created",
  • "status": "delivered",
  • "url": "string",
  • "statusCode": 0,
  • "attemptedAt": "2019-08-24T14:15:22Z",
  • "responseBodyExcerpt": "string",
  • "error": "string",
  • "payload": { }
}

Get all documents for a deal with fresh 4-hour signed URLs

Returns title attachment + every documents-collection row + every platoforms_generated_documents row (Bill of Sale, etc.) linked to this deal. Use this to re-fetch documents after the original webhook signed-URL window has expired.

Authorizations:
bearer
path Parameters
partnerId
required
string

Your partner identifier (e.g. mypartner)

dealId
required
string

Deal Mongo _id

Responses

Response samples

Content type
application/json
{
  • "deal": {
    },
  • "title": [
    ],
  • "documents": [
    ]
}

Get a single document (POA, deal doc) with a fresh signed URL

Returns 404 if the document does not belong to a deal your org is on.

Authorizations:
bearer
path Parameters
partnerId
required
string

Your partner identifier (e.g. mypartner)

documentId
required
string

Document Mongo _id (from documents collection)

Responses

Response samples

Content type
application/json
{
  • "id": "string",
  • "dealId": "string",
  • "docClassification": "string",
  • "status": "string",
  • "signers": [
    ],
  • "signed": [
    ]
}

Get a single generated document (e.g. Bill of Sale) with a fresh signed URL

Returns 404 if the document does not belong to a deal your org is on.

Authorizations:
bearer
path Parameters
partnerId
required
string

Your partner identifier (e.g. mypartner)

documentId
required
string

Generated-document Mongo _id (from platoforms_generated_documents collection)

Responses

Response samples

Content type
application/json
{
  • "id": "string",
  • "docClassification": "string",
  • "name": "string",
  • "signers": [
    ],
  • "signed": [
    ]
}