Building an integration

An integration is an application CardNexus has approved to act on its users' accounts — an inventory-sync platform, a deck builder, a collection manager. Instead of each of your users minting and pasting an API key, your application holds one credential and names the account it's acting on in a header.

This guide covers connecting an account, acting on its behalf, receiving webhooks, and creating accounts for sellers who aren't on CardNexus yet.

Requesting an application

Applications are created by CardNexus — there is no self-serve sign-up. Sign in to cardnexus.com, fill in the application request form and tell us what you're building: what your integration does, whether your users connect their CardNexus account, and which parts of the API it uses. CardNexus creates applications for the use cases it considers valid, and grants each one only the permissions that use case needs.

Getting set up

CardNexus mints your application and gives you:

  • an application credential (cni_live_…) — treat it like a password,
  • your application slug (used in the connect link),
  • one webhook endpoint you configure in your Svix portal.

You keep one credential for your whole platform. It is not tied to any one account.

From then on you manage the application yourself, under Settings → Integrations on cardnexus.com: create and revoke credentials, register the redirect URIs the connect flow may return your users to, open your webhooks portal, and read the rate limits that apply to each connected account.

Connecting an account

You connect an account with an approve-link handshake:

  1. Send the user to https://cardnexus.com/connect/{slug}?redirect_uri={your_callback}&state={state}.
    • redirect_uri must exactly match one of your registered URIs.
    • state is an opaque value you generate per attempt and bind to the user's session. CardNexus returns it unchanged; reject any callback whose state you don't recognise. This is your CSRF protection.
  2. The user signs in, reviews the permissions your application is asking for, and approves.
  3. CardNexus redirects to your redirect_uri with ?code={code}&state={state} (or ?error=access_denied&state={state} if they decline).
  4. Exchange the code, authenticated with your credential:
POST /v1/connect/exchange
Authorization: Bearer cni_live_…
Content-Type: application/json

{ "code": "…" }
{
  "accountId": "65f3a2b1c8d4e9f7a6b5c4d3",
  "scopes": ["inventory:read", "inventory:write"],
  "grantedAt": "2026-02-03T18:42:55.000Z"
}

The code is single-use and expires 5 minutes after it's issued. Store the accountId — it's what you send on every request for that account.

Acting on an account

Send your credential and the account id on each request:

GET /v1/inventory
Authorization: Bearer cni_live_…
CardNexus-Account: 65f3a2b1c8d4e9f7a6b5c4d3

Every /v1 endpoint works exactly as documented; the account id in the header is the only difference from a first-party key. You can only do what the account granted you — a request outside your granted permissions returns 403.

Your application's permissions are listed under Settings → Integrations. To change what it may request, email partnerships@cardnexus.com with the permissions you need and what they're for. Widening an application's permissions never widens the grants accounts have already given it — connected accounts approve the new set the next time they go through the connect flow.

If an account hasn't connected your application (or disconnected it), requests for it return 403. A user disconnects your application from their CardNexus Settings → Integrations at any time; the next request stops working.

Webhooks

Configure one endpoint in your Svix portal. Every connected account's events are delivered there. Each payload carries an account field telling you which account the event belongs to:

{
  "type": "order.created",
  "eventId": "…",
  "timestamp": "2026-02-03T18:42:55.000Z",
  "account": "65f3a2b1c8d4e9f7a6b5c4d3",
  "data": { }
}

Verify the Svix signature with the official libraries, allow a 5-minute timestamp tolerance, and treat (account, eventId) as the idempotency key. You only receive event families your granted permissions cover. When a user disconnects your application, you stop receiving that account's events; deliveries already queued may still retry for a short window, so ignore events for any account you no longer have access to.

Creating accounts for new sellers

If CardNexus has granted your application the managed-account privilege, you can create an account for a seller who isn't on CardNexus yet:

POST /v1/accounts
Authorization: Bearer cni_live_…
Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7
Content-Type: application/json

{
  "email": "shop@northarena.example",
  "country": "FR",
  "sellerType": "pro",
  "username": "north_arena_tcg",
  "firstName": "Léa",
  "lastName": "Martin",
  "language": "fr",
  "business": {
    "companyName": "North Arena TCG",
    "registrationNumber": "912345678",
    "vatNumber": "FR12345678901",
    "address": { "line1": "12 rue des Cartes", "city": "Lyon", "postalCode": "69001", "country": "FR" }
  }
}
{ "accountId": "65f3a2b1c8d4e9f7a6b5c4d3", "username": "north_arena_tcg", "status": "staged" }

email, country and sellerType are required. Use sellerType: "pro" for a registered company and "individual" for a private person. Everything else is optional: username (derived from the email when omitted), firstName and lastName (prefilled into Stripe onboarding), language (en or fr), phone, and for pro sellers the business identity. Whatever you leave out, the seller fills in during onboarding. The kind of VAT number and tax id follows from country, so there is nothing to send for it.

The account starts staged: it already has a seller profile, so you can sync inventory and create listings into it right away, but the listings stay hidden from the marketplace. We email the address a link to claim the account; anyone who did not ask for it can reply to that email to have the account deleted.

To take the account live, send the seller through the hosted onboarding flow:

POST /v1/account/onboarding-link
Authorization: Bearer cni_live_…
CardNexus-Account: 65f3a2b1c8d4e9f7a6b5c4d3
Content-Type: application/json

{ "returnUrl": "https://your-platform.example/cardnexus/done" }

Send the seller to the returned url. In their own browser they see who created the account and what your application can do, accept the CardNexus terms and Stripe's terms, and — as an individual seller — give Stripe their name and date of birth. That's what takes the account live: its listings become visible and the seller returns to your returnUrl. A pro seller goes live as soon as they accept the terms; Stripe collects the business verification afterwards, and payouts are held until it passes.

seller.status on GET /v1/account/me tells you where the account stands: staged while listings are hidden, live once they are shown.

If the email already belongs to an account, POST /v1/accounts returns ACCOUNT_EXISTS — send that seller through the normal connect flow instead.

There is a daily limit on how many accounts you can create. Once you reach it, POST /v1/accounts returns QUOTA_EXCEEDED with the limit you hit and the resetAt timestamp it lifts at. The count resets at midnight UTC.

Sending tracking scans

If your application prints shipping labels, CardNexus can grant it the tracking privilege so you can send the scans you receive for them. This is how letters with a USPS Intelligent Mail barcode (IMb) get tracking on CardNexus: only the application that printed the barcode receives its mail scans.

Mark the sale shipped with the barcode number. CardNexus recognises the IMb, ships the sale as a letter (shipping.type is letter, the carrier is usps), and shows the buyer the trackingUrl you pass:

POST /v1/sales/OR-ASNBC-1/mark-shipped
Authorization: Bearer cni_live_…
CardNexus-Account: 65f3a2b1c8d4e9f7a6b5c4d3
Content-Type: application/json

{
  "trackingNumber": "00310110084032371774",
  "trackingUrl": "https://track.your-platform.example/l/00310110084032371774"
}

Then send the scans with your application credential and no CardNexus-Account header. One request carries up to 100 shipments:

POST /v1/tracking/events
Authorization: Bearer cni_live_…
Content-Type: application/json

{
  "shipments": [
    {
      "trackingNumber": "00310110084032371774",
      "events": [
        {
          "eventId": "evt_8811",
          "status": "label_created",
          "occurredAt": "2026-09-21T10:21:00.000Z",
          "location": { "city": "Laurel", "state": "MD" },
          "description": "Label created"
        },
        {
          "eventId": "evt_8812",
          "status": "in_mailstream",
          "occurredAt": "2026-09-22T23:35:00.000Z",
          "location": { "city": "Gaithersburg", "state": "MD", "postalCode": "20898" },
          "description": "Origin processing"
        }
      ]
    }
  ]
}
{
  "shipments": [
    { "trackingNumber": "00310110084032371774", "matchedSales": 1, "accepted": 1, "duplicates": 1 }
  ]
}
  • Shipments are matched by tracking number to the sales of sellers who connected your application with the sales:write permission. It does not matter whether your application or the seller marked the sale shipped.
  • Send every scan you have for a shipment each time. Scans are matched by eventId: one you already sent counts in duplicates and is recorded once.
  • A tracking number that matches no sale yet returns matchedSales: 0. Once the sale is marked shipped, the next time you send the shipment all of its scans are recorded.
  • A delivered scan marks the sale delivered, for parcels and letters alike.
  • If your application does not have the tracking privilege, the call returns 403 FORBIDDEN.

The scans appear in shipping.history on GET /v1/sales/{orderNumber} with source: "application".

Shipping untracked sales

Every sale carries shippingService, the shipping the buyer paid for. A tracked sale needs a tracking number to be marked shipped. An untracked sale ships as a letter, and you can mark it shipped without one:

POST /v1/sales/OR-ASNBC-1/mark-shipped
Authorization: Bearer cni_live_…
CardNexus-Account: 65f3a2b1c8d4e9f7a6b5c4d3
Content-Type: application/json

{}
  • The sale moves to shipped with shipping.trackingNumber and shipping.type set to null, and untrackedCloseAt set to the date the sale closes on its own.
  • An IMb number is still accepted on an untracked sale: the sale ships as a letter with that number, and the scans you send with POST /v1/tracking/events are recorded as above.
  • Leaving trackingNumber out on a tracked sale returns 409 TRACKING_NUMBER_REQUIRED.