ShipThanks

API reference

ShipThanks turns purchases into personal video thank-yous. Your system tells ShipThanks to start a conversation; ShipThanks texts the seller a one-tap record link; the seller films a short video; the customer receives it by SMS with a watch link — and a two-way conversation between customer and seller is born.

This page covers the integration API: the endpoints callable with an API credential. Everything else (dashboards, recording, replies) happens inside ShipThanks.

  • Base URL: https://api.shipthanks.com
  • All endpoints are under /api/v1
  • Format: JSON over HTTPS

Authentication

An organization admin creates an API credential in the ShipThanks dashboard (Settings → API Credentials). The token is prefixed shpt_ and is shown once at creation — store it in your secret manager.

Send it on every request, either way works:

X-API-Key: shpt_...

or

Authorization: Bearer shpt_...

Credentials are scoped to your organization and carry granular permissions (e.g. conversation:write, customer:read), which an admin can change at any time from the same dashboard page. Each endpoint below lists the permission it requires; calls without it return 403 FORBIDDEN.

Every endpoint is scoped automatically by your credential — you never pass an organization id.

Conventions

Response envelope

Every successful response wraps the payload in data plus a meta block:

{
  "data": { "...": "..." },
  "meta": { "requestId": "req_abc123" }
}

List endpoints are paginated with ?page=1&pageSize=20 (1-based, default 20) and echo pagination in meta:

{
  "data": [ "..." ],
  "meta": { "requestId": "req_abc123", "page": 1, "pageSize": 20, "total": 57, "totalPages": 3 }
}

Data formats

  • Phone numbers are E.164: +15551234567
  • Timestamps are ISO-8601 UTC: 2026-07-01T12:00:00Z
  • Money is integer cents plus an ISO currency code: 4999, "USD"
  • Ids are UUIDs
  • metadata fields accept any JSON object for your own bookkeeping

Quickstart

One call starts everything. When something worth thanking happens — an order, a renewal, a subscription — tell ShipThanks who should thank whom:

curl -X POST https://api.shipthanks.com/api/v1/conversations \
  -H "X-API-Key: $SHIPTHANKS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "externalRef": "ORD-1001",
    "seller": {
      "externalId": "rep-1001",
      "firstName": "Sam",
      "lastName": "Seller",
      "phone": "+15551234567"
    },
    "customer": {
      "firstName": "Casey",
      "lastName": "Customer",
      "phone": "+15557654321",
      "consent": {
        "source": "CHECKOUT",
        "at": "2026-07-01T12:00:00Z",
        "text": "Checked the SMS opt-in box at checkout"
      }
    },
    "event": {
      "type": "ORDER",
      "description": "Monthly protein powder",
      "valueCents": 4999,
      "currency": "USD",
      "occurredAt": "2026-07-01T12:00:00Z"
    }
  }'

What happens next, automatically:

  1. ShipThanks texts the seller a record prompt with a one-tap link.
  2. The seller records a short thank-you video — no app or login required. The event.description is shown to them so they know what to say.
  3. The customer receives an SMS with a watch link to the video.
  4. The customer can reply — the conversation is now live, and the seller is notified of replies.

The seller's phone is where the record prompt is texted. If ShipThanks hasn't seen this seller before, the phone alone is enough — a seller identity is created from it on the fly; no roster upload, no email, no sign-up. Sellers are deduplicated by phone, so send it on every call.

Without seller.phone there is no one to prompt: ShipThanks still accepts the call and stores seller.externalId for attribution, but the conversation is parked rather than dropped — no record prompt goes out, and it surfaces on the dashboard's Impact page as unattributed. Sending the phone on a later call for the same externalRef attributes the parked conversation and releases the prompt.

Conversations

Start a conversation

POST /api/v1/conversations

Permission: conversation:write

Idempotent by externalRef (use your order id or any stable reference): repeating a call returns the same conversation instead of prompting the seller twice, and refreshes the event context. Returns 201.

Request

FieldTypeRequiredNotes
externalRefstringyesYour reference for the triggering event — the idempotency key
event.typestringyesORDER | SHIPMENT | RECURRING | SUBSCRIPTION — the dimension retention analytics slices by
customerobjectone ofInline customer upsert (see Customers fields); matched by phone
customerIdUUIDone ofExisting ShipThanks customer id
seller.externalIdstringrecommendedYour rep id — always stored for attribution
seller.phonestringyesE.164; the record prompt is texted here, and provisions the seller if unknown
seller.firstName / seller.lastNamestringnoUsed when provisioning a new seller
event.descriptionstringnoShown to the seller on the record screen ("Monthly protein powder")
event.valueCentsintegernoPurchase value, when you know it
event.currencystringnoISO code, e.g. "USD"
event.occurredAttimestampnoWhen the event happened
event.metadataobjectnoYour own JSON

Everything inside event beyond type is optional context — it helps the seller know what to say and enriches analytics when present, but a conversation starts fine without it.

Response (data)

{
  "id": "0197...",
  "externalRef": "ORD-1001",
  "customerId": "0197...",
  "sellerId": "0197...",
  "externalSellerId": "rep-1001",
  "status": "PENDING",
  "eventType": "ORDER",
  "promptSentAt": "2026-07-01T12:00:03Z",
  "promptSkippedReason": null,
  "thankYouSentAt": null,
  "createdAt": "2026-07-01T12:00:02Z"
}
  • status — the conversation lifecycle: PENDING (awaiting the seller's recording) → OPEN (thank-you sent, thread live) → CLOSED/ARCHIVED; CANCELLED withdraws the event (refund/cancel) and suppresses any send.
  • promptSentAt — when the seller's record prompt was texted.
  • promptSkippedReason — set when no prompt could go out: ORG_NOT_ACTIVE | NO_PHONE | CUSTOMER_OPTED_OUT | SELLER_UNREACHABLE | SELLER_UNASSIGNED.
  • thankYouSentAt — set once the seller records and sends; the customer-side thread exists from that moment.

How sellers are matched

Resolution order:

  1. seller.externalId — your rep id, matched against sellers already known to ShipThanks
  2. seller.phone — resolves to the existing seller with that phone, or creates one on the fly (using firstName/lastName when provided)

Your externalId is stored in every case, so attribution by rep id survives even when the phone did the matching.

If nothing resolves — an unrecognized rep id and no phone — the call still succeeds but is parked (promptSkippedReason: "SELLER_UNASSIGNED"): no prompt goes out until the seller becomes known. Sending the phone avoids parking entirely.

Customer consent

The customer.consent block (source, at, text) is SMS opt-in evidence — stored as provided, never a gate. Include it the first time you see the customer; omit it afterward. Customers who reply STOP are opted out automatically and skipped on future sends — you don't need to filter.

Read conversations

GET /api/v1/conversations?page=1&pageSize=20     conversation:read
GET /api/v1/conversations/{conversationId}       conversation:read

The list is org-wide, newest first, and paginated per the conventions; each row is the conversation shape shown above plus messageCount and lastMessageAt. The single-conversation response nests the conversation together with its message thread:

{
  "conversation": { "id": "0197...", "externalRef": "ORD-1001", "status": "OPEN", "...": "..." },
  "messages": [
    { "id": "0197...", "senderType": "SELLER", "senderId": "0197...",
      "content": "Thanks again for the order!", "mediaUrl": null,
      "status": "DELIVERED", "sentAt": "2026-07-01T12:05:00Z",
      "createdAt": "2026-07-01T12:04:58Z" }
  ]
}

Use these to sync conversation status back into your system — for example, polling for thankYouSentAt to know the customer received their video.

Customers

End customers, deduplicated by phone number within your organization. Usually you won't call these directly — the inline customer object on conversation-start upserts for you.

Create or update a customer

POST /api/v1/customers

Permission: customer:write — upserts by phone. At least one of email / phone is required.

FieldTypeNotes
phonestringE.164 — the dedup key and SMS destination
emailstring
firstName / lastNamestringUsed to personalize prompts and messages
externalIdstringYour customer id
consent.sourcestringe.g. "CHECKOUT" — consent evidence
consent.attimestampWhen consent was captured
consent.textstringThe consent language shown
metadataobjectYour own JSON

Response additionally carries smsOptedOut / smsOptedOutAt — set when a customer replies STOP. Opted-out customers are skipped automatically; you don't need to filter.

List / get / update

GET /api/v1/customers?page=1&pageSize=20     customer:read
GET /api/v1/customers/{customerId}           customer:read
PUT /api/v1/customers/{customerId}           customer:write   (same fields as create)

Recommended credential permissions

For the standard integration, create a credential with:

conversation:read  conversation:write  customer:read  customer:write

Errors

Errors return a single error object. Keep requestId when contacting support.

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request validation failed",
    "details": { "externalRef": "must not be blank" },
    "requestId": "req_abc123"
  }
}
HTTPCodeMeaning
401UNAUTHORIZEDMissing, invalid, expired, or revoked token
403FORBIDDENCredential lacks the required permission
404RESOURCE_NOT_FOUNDResource does not exist in your organization
409CONFLICTState conflict (e.g. duplicate)
422VALIDATION_ERRORInvalid or missing fields — see details
500INTERNAL_ERRORUnexpected server error
503SERVICE_UNAVAILABLETemporary outage — retry with backoff