{"openapi":"3.1.0","info":{"title":"HopDrop B2B Partner API","version":"1.0.0","description":"Bearer-token authenticated API for partners (e-commerce, marketplaces, pawnshops, restaurants, pharmacies, courier companies) integrating with HopDrop's commuter-delivery platform. See SAPAWN_INTEGRATION.md in the repo for the longer-form integration brief, including webhook event types, signing flow, pricing model, and HopDrop Guarantee terms.","contact":{"name":"HopDrop integrations","email":"support@hopdrop.co.za","url":"https://hopdrop.co.za"}},"servers":[{"url":"https://hopdrop-ium4.onrender.com","description":"Sandbox / production (single deploy today)"},{"url":"http://localhost:3000","description":"Local dev"}],"tags":[{"name":"Partner onboarding","description":"Sign up, register pickup locations, manage shop config"},{"name":"Quotes","description":"Coverage check + price quote"},{"name":"Deliveries","description":"Book, status, cancel, dispute"},{"name":"Webhooks","description":"Configure event delivery"},{"name":"Trust & safety","description":"Report unsafe couriers"},{"name":"POPIA","description":"Data subject rights"}],"components":{"securitySchemes":{"PartnerBearer":{"type":"http","scheme":"bearer","bearerFormat":"hop_<scope>_<40-char-body>","description":"API key issued via POST /v1/partners/signup or HopDrop staff. Hashed with HMAC-SHA256; plaintext is shown ONCE at issuance and cannot be retrieved later."}},"schemas":{"ErrorEnvelope":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string","example":"validation_failed"},"message":{"type":"string","example":"Request body failed validation."},"issues":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}}}},"required":["code"]}},"required":["error"]},"CourierPayload":{"type":"object","properties":{"first_name":{"type":"string","example":"Bob"},"photo_url":{"type":["string","null"],"example":"https://i.pravatar.cc/200?img=12"},"kyc_verified":{"type":"boolean","example":true},"rating_avg":{"type":["number","null"],"example":4.8},"rating_count":{"type":"integer","example":47},"vehicle":{"type":["object","null"],"properties":{"make":{"type":["string","null"],"example":"Toyota"},"model":{"type":["string","null"],"example":"Corolla"},"color":{"type":["string","null"],"example":"Silver"},"plate":{"type":["string","null"],"example":"CA 482 109"}},"required":["make","model","color","plate"]},"phone_masked":{"type":"string","example":"+27 ••• ••• 0002"}},"required":["first_name","photo_url","kyc_verified","rating_avg","rating_count","vehicle","phone_masked"]},"QuoteResponse":{"type":"object","properties":{"quote_id":{"type":"string","format":"uuid"},"service_tier":{"type":"string","enum":["standard","same_day","relay"]},"price_zar":{"type":"number","example":33},"distance_km":{"type":"number","example":33},"estimated_window":{"type":"object","properties":{"min_hours":{"type":"number"},"max_hours":{"type":"number"}},"required":["min_hours","max_hours"]},"breakdown":{"type":"object","properties":{"base_zar":{"type":"number","example":0},"distance_zar":{"type":"number","example":33},"size_surcharge_zar":{"type":"number","example":0},"platform_components_zar":{"type":"number","example":0}},"required":["base_zar","distance_zar","size_surcharge_zar","platform_components_zar"]},"expires_at":{"type":"string","format":"date-time"}},"required":["quote_id","service_tier","price_zar","distance_km","estimated_window","breakdown","expires_at"]},"DeliveryResponse":{"type":"object","properties":{"delivery_id":{"type":"string","format":"uuid"},"reference":{"type":["string","null"]},"cart_reference":{"type":["string","null"]},"status":{"type":"string","example":"open"},"service_tier":{"type":"string","enum":["standard","same_day","relay"]},"price_zar":{"type":"number"},"pickup_label":{"type":"string"},"dropoff_label":{"type":"string"},"tracking_url":{"type":"string","format":"uri"},"created_at":{"type":"string","format":"date-time"},"courier":{"allOf":[{"$ref":"#/components/schemas/CourierPayload"},{"type":["object","null"]}]}},"required":["delivery_id","reference","cart_reference","status","service_tier","price_zar","pickup_label","dropoff_label","tracking_url","created_at","courier"]},"InvoiceSummary":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"invoice_number":{"type":["string","null"],"example":"HOPDROP-202605-0001"},"period_start":{"type":"string","example":"2026-04-01"},"period_end":{"type":"string","example":"2026-04-30"},"delivery_count":{"type":"integer"},"subtotal_zar":{"type":"number"},"vat_zar":{"type":"number"},"total_zar":{"type":"number"},"status":{"type":"string","enum":["draft","issued","paid","void"]},"closed_at":{"type":["string","null"],"format":"date-time"},"due_at":{"type":["string","null"],"format":"date-time"},"paid_at":{"type":["string","null"],"format":"date-time"}},"required":["id","invoice_number","period_start","period_end","delivery_count","subtotal_zar","vat_zar","total_zar","status","closed_at","due_at","paid_at"]}},"parameters":{}},"paths":{"/v1/partners/signup":{"post":{"summary":"Self-serve partner signup (sandbox key)","description":"Public endpoint. Issues a sandbox API key for any new integrator. Rate-limited to 3 signups per IP per hour. Sandbox-only — promotion to live scope requires manual KYC review (email support@hopdrop.co.za). The token is shown once and cannot be retrieved later.","tags":["Partner onboarding"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","minLength":2,"maxLength":120},"contact_email":{"type":"string","maxLength":200,"format":"email"},"contact_phone":{"type":"string","maxLength":20},"partner_type":{"type":"string","enum":["merchant","pro_courier"],"default":"merchant"},"intent":{"type":"string","maxLength":500}},"required":["name","contact_email"]}}}},"responses":{"201":{"description":"Partner created, sandbox key returned","content":{"application/json":{"schema":{"type":"object","properties":{"partner_id":{"type":"string","format":"uuid"},"partner_type":{"type":"string","enum":["merchant","pro_courier"]},"sandbox_key":{"type":"string","example":"hop_sandbox_<40-char-body>"},"key_prefix":{"type":"string"},"docs_url":{"type":"string","format":"uri"},"next_steps":{"type":"array","items":{"type":"string"}}},"required":["partner_id","partner_type","sandbox_key","key_prefix","docs_url","next_steps"]}}}},"409":{"description":"Partner already exists with this email","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"422":{"description":"Validation failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"429":{"description":"Too many signups from this IP","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/coverage":{"get":{"summary":"Check whether HopDrop covers a route","description":"Coarse availability check. Does NOT consume a quote slot. Use before showing the delivery option to the buyer at checkout.","tags":["Quotes"],"security":[{"PartnerBearer":[]}],"parameters":[{"schema":{"type":"string","example":"Johannesburg"},"required":true,"name":"from_city","in":"query"},{"schema":{"type":"string","example":"Cape Town"},"required":true,"name":"to_city","in":"query"}],"responses":{"200":{"description":"Coverage result","content":{"application/json":{"schema":{"type":"object","properties":{"available":{"type":"boolean"},"distance_km":{"type":"number"},"service_tiers":{"type":"array","items":{"type":"string","enum":["standard","same_day","relay"]}}},"required":["available","distance_km","service_tiers"]}}}},"400":{"description":"Missing query params","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"401":{"description":"Missing or invalid Bearer token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/quotes":{"post":{"summary":"Get a delivery quote","description":"Returns a `quote_id` valid for 30 minutes. Pricing is tapered per-km (R1.00 / R0.80 / R0.50 / R0.30 across 50/150/400 km brackets) × size multiplier (envelope/small ×1.0, medium ×1.5, large ×2.5) × service tier multiplier (standard 1.0, same_day 1.5, relay 0.85). See SAPAWN_INTEGRATION.md §7.","tags":["Quotes"],"security":[{"PartnerBearer":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"pickup_location_id":{"type":"string","format":"uuid"},"dropoff":{"type":"object","properties":{"address_line1":{"type":"string","minLength":2,"maxLength":200},"address_line2":{"type":"string","maxLength":200},"suburb":{"type":"string","maxLength":100},"city":{"type":"string","minLength":2,"maxLength":100},"province":{"type":"string","maxLength":100},"postal_code":{"type":"string","maxLength":20},"country":{"type":"string","minLength":2,"maxLength":2,"default":"ZA"},"lat":{"type":"number","minimum":-90,"maximum":90},"lng":{"type":"number","minimum":-180,"maximum":180}},"required":["address_line1","city"]},"parcel":{"type":"object","properties":{"size":{"type":"string","enum":["envelope","small","medium","large"]},"weight_grams":{"type":"integer","minimum":0,"maximum":50000},"length_cm":{"type":"integer","minimum":0,"maximum":200},"width_cm":{"type":"integer","minimum":0,"maximum":200},"height_cm":{"type":"integer","minimum":0,"maximum":200},"declared_value_zar":{"type":"number","minimum":0,"maximum":20000}},"required":["size","declared_value_zar"]},"service_tier":{"type":"string","enum":["standard","same_day","relay"],"default":"standard"}},"required":["pickup_location_id","dropoff","parcel"]}}}},"responses":{"201":{"description":"Quote created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuoteResponse"}}}},"401":{"description":"Missing or invalid Bearer token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"422":{"description":"Validation failed or address not geocodable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/deliveries":{"post":{"summary":"Book a delivery from a quote","description":"Consumes a `quote_id` and creates a delivery. Returns an anonymous tracking URL safe to share with buyers.","tags":["Deliveries"],"security":[{"PartnerBearer":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"quote_id":{"type":"string","format":"uuid"},"reference":{"type":"string","maxLength":120},"cart_reference":{"type":"string","maxLength":120},"buyer_name":{"type":"string","minLength":2,"maxLength":120},"buyer_phone":{"type":"string","minLength":7,"maxLength":20},"buyer_email":{"type":"string","maxLength":200,"format":"email"},"special_instructions":{"type":"string","maxLength":500}},"required":["quote_id","reference","buyer_name","buyer_phone"]}}}},"responses":{"201":{"description":"Delivery booked","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeliveryResponse"}}}},"401":{"description":"Missing or invalid Bearer token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"410":{"description":"Quote expired or already consumed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"422":{"description":"Validation failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/deliveries/{id}":{"get":{"summary":"Get delivery status + assigned courier identity","description":"Returns the current status and the assigned courier identity (POPIA-minimal — first name, photo, KYC flag, rating, vehicle, masked phone). `courier` is null until a courier accepts leg 0.","tags":["Deliveries"],"security":[{"PartnerBearer":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Delivery details","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeliveryResponse"}}}},"401":{"description":"Missing or invalid Bearer token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"404":{"description":"Delivery not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/deliveries/{id}/cancel":{"post":{"summary":"Cancel a delivery","description":"Pre-pickup: full refund. After pickup: 50% retained. After delivery: returns 409, open a dispute instead.","tags":["Deliveries"],"security":[{"PartnerBearer":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Cancellation accepted","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"status":{"type":"string","enum":["cancelled"]},"cost_breakdown":{"type":"object","properties":{"price_zar":{"type":"number"},"fee_retained_zar":{"type":"number"},"refund_amount_zar":{"type":"number"},"reason":{"type":"string"}},"required":["price_zar","fee_retained_zar","refund_amount_zar","reason"]}},"required":["ok","status","cost_breakdown"]}}}},"401":{"description":"Missing or invalid Bearer token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"404":{"description":"Delivery not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"409":{"description":"Already cancelled or already delivered","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/deliveries/{id}/report_unsafe":{"post":{"summary":"Report an unsafe / wrong courier (sender side)","description":"Pawnshop staff flag a courier whose photo/plate doesn't match the person at the counter. Cancels the delivery, refunds in full, pushes the report to T&S queue. Pre-pickup only.","tags":["Deliveries","Trust & safety"],"security":[{"PartnerBearer":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"reason":{"type":"string","minLength":5,"maxLength":500}},"required":["reason"]}}}},"responses":{"200":{"description":"Report logged, delivery cancelled, courier notified","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"status":{"type":"string","enum":["cancelled"]},"refund":{"type":"string","enum":["full"]}},"required":["ok","status","refund"]}}}},"400":{"description":"No courier assigned yet","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"409":{"description":"Cannot report after pickup — open a dispute instead","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/partners/locations":{"post":{"summary":"Register a pickup origin / drop-point shop","description":"Idempotent on `(partner_id, partner_reference)` — re-posting the same reference updates the row (and reactivates if it was soft-deleted). Address is geocoded with a fallback chain (street → city → suburb → city+province → city); pass `lat` and `lng` directly to skip Nominatim entirely.","tags":["Partner onboarding"],"security":[{"PartnerBearer":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"partner_reference":{"type":"string","maxLength":120},"name":{"type":"string","minLength":2,"maxLength":120},"address_line1":{"type":"string","minLength":2,"maxLength":200},"address_line2":{"type":"string","maxLength":200},"suburb":{"type":"string","maxLength":100},"city":{"type":"string","minLength":2,"maxLength":100},"province":{"type":"string","maxLength":100},"postal_code":{"type":"string","maxLength":20},"contact_phone":{"type":"string","maxLength":20},"hours":{"type":"string","maxLength":200},"is_pickup_origin":{"type":"boolean","default":true},"is_drop_point":{"type":"boolean","default":false},"drop_point_capacity":{"type":"integer","minimum":1,"maximum":500},"lat":{"type":"number"},"lng":{"type":"number"}},"required":["name","address_line1","city"]}}}},"responses":{"201":{"description":"Location registered","content":{"application/json":{"schema":{"type":"object","properties":{"location_id":{"type":"string","format":"uuid"},"partner_reference":{"type":["string","null"]},"is_drop_point":{"type":"boolean"},"staff_release_pin":{"type":["string","null"],"example":"928374"}},"required":["location_id","partner_reference","is_drop_point","staff_release_pin"]}}}},"401":{"description":"Missing or invalid Bearer token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"422":{"description":"Address not geocodable (or validation failed)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}},"get":{"summary":"List partner locations","description":"Returns active locations by default. Pass `?include_inactive=true` to include soft-deleted rows.","tags":["Partner onboarding"],"security":[{"PartnerBearer":[]}],"parameters":[{"schema":{"type":"string","example":"true"},"required":false,"name":"include_inactive","in":"query"}],"responses":{"200":{"description":"Location list","content":{"application/json":{"schema":{"type":"object","properties":{"locations":{"type":"array","items":{"type":"object","additionalProperties":{}}}},"required":["locations"]}}}},"401":{"description":"Missing or invalid Bearer token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/partners/locations/{id}":{"delete":{"summary":"Soft-delete a partner location","description":"Sets `is_active = false`. Row is retained for FK integrity (historical deliveries still reference it). Re-POSTing the same `partner_reference` reactivates.","tags":["Partner onboarding"],"security":[{"PartnerBearer":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Soft-deleted","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"}},"required":["ok"]}}}},"401":{"description":"Missing or invalid Bearer token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"404":{"description":"Location not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/partners/webhooks":{"post":{"summary":"Register a webhook endpoint","description":"Returns the HMAC-SHA256 `signing_secret` ONCE. Store it immediately — incoming webhooks are signed `t=<unix-ts>,v1=<hex>` over `${ts}.${rawBody}`. Verify with 5-minute timestamp tolerance.","tags":["Webhooks"],"security":[{"PartnerBearer":[]}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","format":"uri"},"events":{"type":"array","items":{"type":"string"},"minItems":1}},"required":["url"]}}}},"responses":{"201":{"description":"Webhook registered","content":{"application/json":{"schema":{"type":"object","properties":{"endpoint_id":{"type":"string","format":"uuid"},"url":{"type":"string","format":"uri"},"signing_secret":{"type":"string","example":"whsec_<64-char-hex>"}},"required":["endpoint_id","url","signing_secret"]}}}},"401":{"description":"Missing or invalid Bearer token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"422":{"description":"Validation failed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}},"get":{"summary":"List webhook endpoints","tags":["Webhooks"],"security":[{"PartnerBearer":[]}],"responses":{"200":{"description":"Webhook list","content":{"application/json":{"schema":{"type":"object","properties":{"webhook_endpoints":{"type":"array","items":{"type":"object","additionalProperties":{}}}},"required":["webhook_endpoints"]}}}},"401":{"description":"Missing or invalid Bearer token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/customers/{phone}":{"delete":{"summary":"Delete a buyer's data (POPIA right-to-erasure)","description":"POPIA compliance — buyer requests data deletion. Removes all PII tied to the phone (name, email, address) but retains anonymised delivery records for our own legal obligations.","tags":["POPIA"],"security":[{"PartnerBearer":[]}],"parameters":[{"schema":{"type":"string","example":"+27821234567"},"required":true,"name":"phone","in":"path"}],"responses":{"200":{"description":"Erasure complete","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"records_anonymised":{"type":"integer"}},"required":["ok","records_anonymised"]}}}},"401":{"description":"Missing or invalid Bearer token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/partners/webhooks/{id}/rotate-secret":{"post":{"summary":"Rotate the HMAC signing secret","description":"Returns the NEW signing secret once. The previous secret remains valid for `grace_seconds` (default 24h) so the partner can update their verifier without missing events. Recommended use: staff turnover, suspected leak, or a scheduled rotation cadence (e.g. every 90 days).","tags":["Webhooks"],"security":[{"PartnerBearer":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"grace_seconds":{"type":"integer","minimum":0,"maximum":604800,"default":86400,"example":86400}}}}}},"responses":{"200":{"description":"Rotated; new secret returned ONCE","content":{"application/json":{"schema":{"type":"object","properties":{"new_signing_secret":{"type":"string","example":"whsec_<64-char-hex>"},"previous_secret_expires_at":{"type":"string","format":"date-time"},"note":{"type":"string"}},"required":["new_signing_secret","previous_secret_expires_at","note"]}}}},"404":{"description":"Endpoint not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/partners/webhooks/deliveries":{"get":{"summary":"List recent webhook delivery attempts","description":"For partner debugging. `?status=failed` returns only failures (failed_permanently or pending).","tags":["Webhooks"],"security":[{"PartnerBearer":[]}],"parameters":[{"schema":{"type":"string","enum":["failed"]},"required":false,"name":"status","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":200,"default":50},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"Delivery attempts","content":{"application/json":{"schema":{"type":"object","properties":{"deliveries":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"event_type":{"type":"string"},"event_id":{"type":"string"},"url":{"type":"string","format":"uri"},"status":{"type":"string","enum":["pending","delivered","failed_permanently"]},"attempts":{"type":"integer"},"last_error":{"type":["string","null"]},"next_attempt_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"},"delivered_at":{"type":["string","null"],"format":"date-time"}},"required":["id","event_type","event_id","url","status","attempts","last_error","next_attempt_at","created_at","delivered_at"]}}},"required":["deliveries"]}}}},"401":{"description":"Missing or invalid Bearer token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/partners/webhooks/deliveries/{id}/replay":{"post":{"summary":"Replay a webhook delivery","description":"Re-fires a previously failed (or already delivered) webhook. Resets attempts and queues for the worker to pick up within 5 seconds. Useful when your endpoint was down during the original 9-attempt retry chain.","tags":["Webhooks"],"security":[{"PartnerBearer":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Re-queued","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"status":{"type":"string","enum":["pending"]},"note":{"type":"string"}},"required":["ok","status","note"]}}}},"404":{"description":"Webhook delivery not found, or currently retrying","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/invoices":{"get":{"summary":"List partner invoices","description":"Monthly statements. Closed on the 1st of each month for the prior month. NET 14 days payment terms.","tags":["Invoicing"],"security":[{"PartnerBearer":[]}],"parameters":[{"schema":{"type":"string","enum":["draft","issued","paid","void"]},"required":false,"name":"status","in":"query"}],"responses":{"200":{"description":"Invoice list","content":{"application/json":{"schema":{"type":"object","properties":{"invoices":{"type":"array","items":{"$ref":"#/components/schemas/InvoiceSummary"}}},"required":["invoices"]}}}},"401":{"description":"Missing or invalid Bearer token","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/invoices/{id}":{"get":{"summary":"Get an invoice with full line items","tags":["Invoicing"],"security":[{"PartnerBearer":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Invoice with lines","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/InvoiceSummary"},{"type":"object","properties":{"lines":{"type":"array","items":{"type":"object","properties":{"delivery_id":{"type":"string","format":"uuid"},"reference":{"type":["string","null"]},"delivered_at":{"type":"string","format":"date-time"},"fee_zar":{"type":"number"},"service_tier":{"type":"string","enum":["standard","same_day","relay"]},"pickup_label":{"type":"string"},"dropoff_label":{"type":"string"}},"required":["delivery_id","reference","delivered_at","fee_zar","service_tier","pickup_label","dropoff_label"]}}},"required":["lines"]}]}}}},"404":{"description":"Invoice not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/invoices/{id}/lines.csv":{"get":{"summary":"Download invoice line items as CSV","description":"Same data as `GET /v1/invoices/{id}` but as a CSV download with proper Content-Disposition. Columns: delivery_id, reference, delivered_at, service_tier, pickup, dropoff, fee_zar.","tags":["Invoicing"],"security":[{"PartnerBearer":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"CSV file","content":{"text/csv":{"schema":{"type":"string"}}}},"404":{"description":"Invoice not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/carts/{cart_reference}":{"get":{"summary":"Get all deliveries in a cart","description":"Roll-up view for multi-pickup orders. When you book multiple deliveries with the same `cart_reference`, this returns the whole set with an `aggregate_status` that respects the lowest-progress leg.","tags":["Carts"],"security":[{"PartnerBearer":[]}],"parameters":[{"schema":{"type":"string","example":"ORDER-1234"},"required":true,"name":"cart_reference","in":"path"}],"responses":{"200":{"description":"Cart with deliveries","content":{"application/json":{"schema":{"type":"object","properties":{"cart_reference":{"type":"string"},"aggregate_status":{"type":"string","example":"in_progress"},"total_price_zar":{"type":"number"},"delivery_count":{"type":"integer"},"deliveries":{"type":"array","items":{"type":"object","properties":{"delivery_id":{"type":"string","format":"uuid"},"reference":{"type":["string","null"]},"status":{"type":"string"},"service_tier":{"type":"string","enum":["standard","same_day","relay"]},"price_zar":{"type":"number"},"pickup_label":{"type":"string"},"dropoff_label":{"type":"string"},"tracking_url":{"type":"string","format":"uri"},"created_at":{"type":"string","format":"date-time"}},"required":["delivery_id","reference","status","service_tier","price_zar","pickup_label","dropoff_label","tracking_url","created_at"]}}},"required":["cart_reference","aggregate_status","total_price_zar","delivery_count","deliveries"]}}}},"404":{"description":"Cart not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/carts/{cart_reference}/cancel":{"post":{"summary":"Cancel an entire cart","description":"Applies normal per-delivery cancellation rules (free pre-pickup, 50% retained post-pickup, 409 on already-delivered) to every delivery in the cart. Returns a per-delivery breakdown.","tags":["Carts"],"security":[{"PartnerBearer":[]}],"parameters":[{"schema":{"type":"string"},"required":true,"name":"cart_reference","in":"path"}],"responses":{"200":{"description":"Per-delivery cancellation outcomes","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"cart_reference":{"type":"string"},"total_refund_zar":{"type":"number"},"deliveries":{"type":"array","items":{"type":"object","properties":{"delivery_id":{"type":"string","format":"uuid"},"reference":{"type":["string","null"]},"outcome":{"type":"string","enum":["cancelled","already_cancelled","cannot_cancel_delivered"]},"refund_amount_zar":{"type":"number"},"fee_retained_zar":{"type":"number"}},"required":["delivery_id","reference","outcome","refund_amount_zar","fee_retained_zar"]}}},"required":["ok","cart_reference","total_refund_zar","deliveries"]}}}},"404":{"description":"Cart not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/sandbox/deliveries/{id}/advance":{"post":{"summary":"Advance a sandbox delivery to a status","description":"Sandbox-scoped keys only (live keys → 403). Drives a delivery through any state in the lifecycle and fires the matching webhook. Useful for end-to-end webhook flow testing without a real courier.","tags":["Sandbox"],"security":[{"PartnerBearer":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"to_status":{"type":"string","enum":["assigned","picked_up","at_drop_point","out_for_delivery","delivered","delivery_failed"]},"reason":{"type":"string","maxLength":200}},"required":["to_status"]}}}},"responses":{"200":{"description":"Transition applied","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"delivery_id":{"type":"string","format":"uuid"},"new_status":{"type":"string"},"note":{"type":"string"}},"required":["ok","delivery_id","new_status","note"]}}}},"403":{"description":"Live keys cannot use sandbox endpoints","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"409":{"description":"Delivery already in a terminal state","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/sandbox/deliveries/{id}/run-full-flow":{"post":{"summary":"Auto-progress a delivery through assigned → picked_up → delivered","description":"Returns 200 immediately and fires the three webhooks 2-3 seconds apart in the background. Saves you from making three /advance calls in CI.","tags":["Sandbox"],"security":[{"PartnerBearer":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Auto-progression scheduled","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"delivery_id":{"type":"string","format":"uuid"},"note":{"type":"string"}},"required":["ok","delivery_id","note"]}}}}}}},"/v1/sandbox/sms":{"get":{"summary":"Read recent SMS sent to sandbox phones","description":"Captures any SMS sent to a phone starting with `+27999` into a 200-entry in-memory ring buffer. Lets you read OTPs, delivery PINs, and any other transactional SMS from a script without owning a real SIM. Most-recent first. Buffer is process-local — resets on backend restart.","tags":["Sandbox"],"security":[{"PartnerBearer":[]}],"parameters":[{"schema":{"type":"string","example":"+27999000001"},"required":false,"name":"to","in":"query"}],"responses":{"200":{"description":"Recent SMS messages","content":{"application/json":{"schema":{"type":"object","properties":{"messages":{"type":"array","items":{"type":"object","properties":{"to":{"type":"string"},"body":{"type":"string"},"received_at":{"type":"string","format":"date-time"}},"required":["to","body","received_at"]}}},"required":["messages"]}}}},"400":{"description":"Phone is not a sandbox phone (must start with +27999)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/partners/locations/{id}/rotate-release-pin":{"post":{"summary":"Rotate the per-shop staff release PIN","description":"Generates a new 6-digit PIN for relay drop-point handovers, invalidating the old PIN immediately. Use after staff turnover, suspected leak, or as part of routine rotation. Drop-point locations only (returns 400 on a non-drop-point location).","tags":["Partner onboarding"],"security":[{"PartnerBearer":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"New PIN returned ONCE","content":{"application/json":{"schema":{"type":"object","properties":{"location_id":{"type":"string","format":"uuid"},"staff_release_pin":{"type":"string","example":"928374"},"rotated_at":{"type":"string","format":"date-time"},"note":{"type":"string"}},"required":["location_id","staff_release_pin","rotated_at","note"]}}}},"400":{"description":"Not a drop-point location","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"404":{"description":"Location not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/pro-courier/jobs":{"get":{"summary":"List open jobs available to claim","description":"Pro-courier-scoped keys only (merchant keys → 403). Returns jobs the partner can accept, optionally filtered by location and minimum payout.","tags":["Pro-courier"],"security":[{"PartnerBearer":[]}],"parameters":[{"schema":{"type":"number","minimum":-90,"maximum":90},"required":false,"name":"near_lat","in":"query"},{"schema":{"type":"number","minimum":-180,"maximum":180},"required":false,"name":"near_lng","in":"query"},{"schema":{"type":"number","minimum":1,"maximum":500},"required":false,"name":"radius_km","in":"query"},{"schema":{"type":"string","enum":["standard","same_day","relay"]},"required":false,"name":"service_tier","in":"query"},{"schema":{"type":"number","minimum":0},"required":false,"name":"min_payout_zar","in":"query"},{"schema":{"type":"integer","minimum":1,"maximum":200,"default":50},"required":false,"name":"limit","in":"query"}],"responses":{"200":{"description":"Open jobs","content":{"application/json":{"schema":{"type":"object","properties":{"jobs":{"type":"array","items":{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"parcel_size":{"type":"string","enum":["envelope","small","medium","large"]},"declared_value_zar":{"type":"number"},"description":{"type":["string","null"]},"pickup_label":{"type":"string"},"pickup_lat":{"type":"number"},"pickup_lng":{"type":"number"},"dropoff_label":{"type":"string"},"dropoff_lat":{"type":"number"},"dropoff_lng":{"type":"number"},"total_distance_km":{"type":"number"},"payout_zar":{"type":"number"},"service_tier":{"type":"string","enum":["standard","same_day","relay"]},"created_at":{"type":"string","format":"date-time"}},"required":["job_id","parcel_size","declared_value_zar","description","pickup_label","pickup_lat","pickup_lng","dropoff_label","dropoff_lat","dropoff_lng","total_distance_km","payout_zar","service_tier","created_at"]}}},"required":["jobs"]}}}},"403":{"description":"Account is not partner_type=pro_courier","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/pro-courier/jobs/{id}/accept":{"post":{"summary":"Claim a job","description":"Atomic — first accept wins. Materialises a job_leg under a sentinel \"Pro\" courier user owned by the partner; the rest of the platform (PIN flow, payout, photo proof) works unchanged.","tags":["Pro-courier"],"security":[{"PartnerBearer":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"responses":{"200":{"description":"Claimed","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"leg_id":{"type":"string","format":"uuid"},"payout_zar":{"type":"number"},"note":{"type":"string"}},"required":["ok","leg_id","payout_zar","note"]}}}},"409":{"description":"Another courier accepted first","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}},"/v1/pro-courier/jobs/{id}/status":{"post":{"summary":"Drive a claimed job through pickup → delivered","description":"Transition a claimed job. Pass the pickup_otp / delivery_otp (provided by the sender / recipient) to verify custody. Forwards the matching webhook to the buying partner so their UI updates in real time.","tags":["Pro-courier"],"security":[{"PartnerBearer":[]}],"parameters":[{"schema":{"type":"string","format":"uuid"},"required":true,"name":"id","in":"path"}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["picked_up","delivered","failed"]},"pickup_otp":{"type":"string","pattern":"^\\d{4,6}$"},"delivery_otp":{"type":"string","pattern":"^\\d{4,6}$"},"reason":{"type":"string","maxLength":200}},"required":["status"]}}}},"responses":{"200":{"description":"Status applied","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean"},"new_status":{"type":"string"}},"required":["ok","new_status"]}}}},"400":{"description":"OTP mismatch","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}},"409":{"description":"Cannot transition from current status","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorEnvelope"}}}}}}}},"webhooks":{}}