Track Sale
Record an attributed sale and create the partner commission from your backend
Last updated Oct 15, 2026
Call this endpoint from your server after a payment succeeds to record the conversion and trigger commission creation.
Endpoint
POST https://api.affitor.com/api/v1/track/sale
Authorization: Bearer YOUR_PROGRAM_API_KEY
Content-Type: application/jsonAuthentication
All requests require a program API key sent as a Bearer token in the Authorization header. The API key is found in your program settings in the Affitor dashboard.
Authorization: Bearer YOUR_PROGRAM_API_KEYRequest Fields
Your unique payment identifier. Used for duplicate detection — re-sending the same value returns 409.
Sale amount in the smallest currency unit (e.g. cents for USD). Must be a positive integer.
The tax included in amount_cents, in the same unit. Accepted but not used yet: commissions are calculated on amount_cents as sent.
Your internal customer/user ID, set during the signup tracking step. Used to resolve attribution. At least one of customer_key or click_id must match an existing customer record.
The Affitor click ID (affitor_click_id cookie value). Used as fallback attribution when customer_key is not provided.
USD, EUR, or VND. Defaults to USD. Another code returns 422 unsupported_currency and creates no sale, commission or fee. Amounts are not converted between currencies: commissions and platform fees are calculated on the amount as sent, and invoices are issued in USD.
"payment" (default) or "subscription".
paymentsubscriptionArbitrary line-item detail to store with the sale record.
true if this is a recurring billing charge. Defaults to false.
Your subscription identifier. Required for proper recurring commission attribution.
"monthly", "quarterly", or "annual".
monthlyquarterlyannualYour product identifier. Used for per-product commission policy matching.
When the payment happened, as ISO 8601 with an offset (for example 2026-10-16T09:30:00Z), no more than 30 days old and at most 5 minutes in the future. Stored for reporting only: commissions, attribution windows and hold periods still use the time Affitor receives the call.
Your payment provider. Stored for reporting.
stripepolardodopaddlelemonsqueezycreemsepaypayosapipayx402otherThe provider's own payment id (1 to 255 characters). Stored for reporting; duplicate detection still uses transaction_id.
false records a test sale, the same as additional_data.test_mode: true. true or omitted does not force a live sale: additional_data.test_mode: true or a test API key still records a test sale.
Request Body Example
curl -X POST https://api.affitor.com/api/v1/track/sale \
-H "Authorization: Bearer YOUR_PROGRAM_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"transaction_id": "txn_abc123",
"customer_key": "usr_9876",
"amount_cents": 4900,
"currency": "USD",
"sale_type": "payment"
}'Subscription example
{
"transaction_id": "inv_sub_001",
"customer_key": "usr_9876",
"amount_cents": 4900,
"currency": "USD",
"sale_type": "subscription",
"is_recurring": true,
"subscription_id": "sub_stripe_xyz",
"subscription_interval": "monthly",
"product_id": "prod_pro_plan"
}Response
{
"success": true,
"sale_id": 42,
"commission_id": 17,
"message": "Sale tracked successfully"
}| Field | Type | Description |
|---|---|---|
success | boolean | true on success. |
sale_id | integer or null | ID of the created sale event record; null when the attribution window expired. |
commission_id | integer or null | ID of the created commission record; null when the attribution window expired. |
message | string | Human-readable confirmation. |
Re-sending the same transaction_id returns 409 Conflict with error.code duplicate_transaction and the first sale's id in error.details.sale_id. Affitor does not create a duplicate sale, so you can safely retry on network failure.
A 409 response means the sale was already recorded successfully. Do not retry a 409 — treat it as a successful prior call and continue your payment flow normally.
Error Responses
Most errors use the platform envelope { "data": null, "error": { "status", "name", "message", "details", "code" } }. A retired API key and rate limiting use the typed envelope { "error": { "code", "message", ... } }. Either way, branch on error.code; the message text may change.
| Status | error.code | When |
|---|---|---|
400 | invalid_request | transaction_id missing or not a string; amount_cents not a positive integer; an invalid occurred_at, provider, provider_payment_id or livemode |
400 | no_attribution | Neither customer_key nor click_id resolved a customer, the customer belongs to another program, or no partner relationship was found |
401 | unauthorized | Missing or malformed Authorization header, or the API key does not match any active program |
401 | api_key_rotation_required | A retired API key (the body also carries hint and docs_url) |
409 | duplicate_transaction | transaction_id already recorded; error.details.sale_id is the recorded sale |
422 | unsupported_currency | currency is not USD, EUR or VND; no sale, commission or fee is created |
429 | rate_limited | Too many requests; wait retry_after_seconds |
500 | internal_error | Sale or commission creation failed internally |
Test Mode
Send additional_data.test_mode: true (or livemode: false) to create a test sale event without affecting production data. The API key is still required but transaction_id and amount_cents are optional in test mode.
{
"additional_data": {
"test_mode": true
}
}Test mode response:
{
"success": true,
"message": "Test sale event tracked successfully",
"data": {
"eventId": 12,
"programId": 3,
"test_mode": true
}
}