" }` |
| `card_issuer_reference` | string | Issuer's reference for this state (not globally unique) |
| `card_funding` | string | `debit` or `credit` — from the registered card |
| `card_type` | string | `private`, `corporate`, `corporate_with_private_invoice` |
| `original_amount` | float | Amount at merchant |
| `original_currency` | string | ISO 4217 |
| `accounting_amount` | float | Amount in customer's home currency |
| `accounting_currency` | string | ISO 4217 |
| `purchase_merchant` | string | Full merchant name |
| `purchase_merchant_common` | string | Normalized merchant name |
| `mcc_code` | string | Merchant Category Code |
### `receipt.fetched`
Matches the sample file at the URL (Coop, 109.38 SEK, same purchase as the transaction above).
```json theme={null}
{
"url": "https://api.opencard.io/files/receipts/abc123",
"content_type": "application/pdf",
"expiration_time": "2022-10-02 11:12:14",
"purchase_merchant": "Coop",
"original_amount": 109.38,
"original_currency": "SEK",
"purchase_date": "2022-09-02",
"purchase_time": "11:12:14",
"transaction": {
"id": "10",
"card_issuer": "nordea"
},
"payment_product": {
"id": 1,
"name": "Acme Corporate Card"
},
"card_holder": { "reference_id": "employee_john_42" },
"organization": { "reference_id": "client_acme_001" }
}
```
Receipt URLs expire. Download and store the file, don't hotlink.
### `transaction.true.vat`
VAT bands from the Coop receipt (12% + 25%):
```json theme={null}
{
"transaction": { "id": "10", "card_issuer": "nordea" },
"payment_product": {
"id": 1,
"name": "Acme Corporate Card"
},
"organization": { "reference_id": "client_acme_001" },
"card_holder": { "reference_id": "employee_john_42" },
"purchase_country": "SE",
"vat_rates": [
{
"vat_amount": 5.52,
"taxable_amount": 45.96,
"total_amount": 51.48,
"percentage": 12,
"currency": "SEK"
},
{
"vat_amount": 11.58,
"taxable_amount": 46.32,
"total_amount": 57.90,
"percentage": 25,
"currency": "SEK"
}
],
"totals": [
{
"vat_amount": 17.10,
"taxable_amount": 92.28,
"total_amount": 109.38,
"currency": "SEK"
}
]
}
```
### `transaction.line.items`
Line items from the same Coop receipt:
```json theme={null}
{
"transaction": { "id": "10", "card_issuer": "nordea" },
"payment_product": {
"id": 1,
"name": "Acme Corporate Card"
},
"organization": { "reference_id": "client_acme_001" },
"card_holder": { "reference_id": "employee_john_42" },
"line_items": [
{
"description": "SERV 33CM VIT",
"amount": 12.95,
"total_amount": 12.95,
"quantity": 1,
"currency": "SEK"
},
{
"description": "VÄRMELJUS STEARIN",
"amount": 44.95,
"total_amount": 44.95,
"quantity": 1,
"currency": "SEK"
},
{
"description": "GODIS LÖSVIKT",
"amount": 11.68,
"total_amount": 11.68,
"quantity": 0.195,
"currency": "SEK"
},
{
"description": "ZUCCHINI GRÖN ST",
"amount": 9.95,
"total_amount": 39.80,
"quantity": 4,
"currency": "SEK"
}
]
}
```
### `environmental.impact.aland_index` / `deedster`
```json theme={null}
{
"carbon_emission_in_grams": 0.66,
"carbon_social_cost_amount": 2.1,
"carbon_social_cost_currency": "SEK",
"transaction": { "id": "10", "card_issuer": "nordea" },
"payment_product": {
"id": 1,
"name": "Acme Corporate Card"
},
"organization": { "reference_id": "client_acme_001" },
"card_holder": { "reference_id": "employee_john_42" }
}
```
# Webhook Setup
Source: https://developers.opencard.io/ems/webhooks/setup
Create an OpenCard webhook, complete the verification challenge, configure authentication headers, and route events per organization for your EMS integration.
Webhooks are how OpenCard talks to your app. Every transaction, receipt, and lifecycle event gets POSTed to your URL.
***
## Create webhook
```
POST /accounts/{accountId}/organizations/{organizationId}/webhooks
Scope: webhooks-write
```
```json theme={null}
{
"url": "https://your-app.com/hooks/opencard",
"enabled": true,
"authentication_type": "basic",
"basic_username": "opencard",
"basic_password": "your-secret-password",
"card_transaction_authorized": true,
"card_transaction_cleared": true,
"card_transaction_deleted": true,
"card_transaction_invoiced": false,
"card_holder_created": true,
"card_holder_identified": true,
"card_holder_signed_pdpc": true,
"card_holder_deleted": true,
"receipt_fetched": true,
"transaction_true_vat": true,
"transaction_line_items": true,
"tpa_signed": true
}
```
| Field | Notes |
| - | - |
| `url` | Your HTTPS endpoint. Must be reachable from internet. |
| `secret` | Auto-generated 60-char string if omitted. Used for challenge HMAC. |
| `enabled` | `true` to activate (after challenge passes) |
| `authentication_type` | `none`, `basic`, `oauth`, or `custom` |
| Event flags | Boolean per event type. Only subscribed events are delivered. |
Response includes `secret` — **save it**. You need it for challenge verification.
***
## Challenge handshake 🔐
Immediately after create (or URL change), OpenCard verifies you own the endpoint:
```
GET https://your-app.com/hooks/opencard?challenge=RANDOM_20_CHAR_STRING
Header: X-Event: challenge
```
Your server must respond `200` with header:
```
X-Verify-Token: HMAC-SHA256(challenge_string, webhook_secret)
```
**Node.js example:**
```javascript theme={null}
const crypto = require('crypto');
app.get('/hooks/opencard', (req, res) => {
const challenge = req.query.challenge;
if (!challenge) return res.status(400).send('missing challenge');
const token = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(challenge)
.digest('hex');
res.set('X-Verify-Token', token).status(200).send('ok');
});
```
If HMAC matches → `active=true`. If not → `active=false`, no events delivered.
***
## Receiving events
Events come as `POST` to your URL:
```
POST https://your-app.com/hooks/opencard
X-Event: card.transaction.authorized
Content-Type: application/json
{ ... payload ... }
```
Plus auth headers if configured:
| `authentication_type` | What OpenCard sends |
| - | - |
| `none` | Just `X-Event` |
| `basic` | `Authorization: Basic base64(user:pass)` |
| `custom` | Your `custom_key: custom_value` as HTTP header |
| `oauth` | Bearer token fetched from your `oauth_access_token_url` |
**Always respond `200` quickly.** Process async if needed. OpenCard retries on failure.
***
## Custom headers
Add extra headers OpenCard includes on every delivery:
```
POST .../webhooks/{webhookId}/headers
{ "key": "X-My-App-Id", "value": "ems-prod-001" }
```
***
## Webhook groups (buyer-party filter)
Filter events by card issuer buyer party:
```
POST .../webhooks/{webhookId}/groups
{ "buyer_party": "5561234567" }
```
Only transactions matching that buyer party hit this webhook.
***
## Test an event
```
POST .../webhooks/{webhookId}/test/card.transaction.authorized
```
Sends a test payload to your endpoint. Good for verifying your handler works.
***
## Delivery log
```
GET .../webhooks/{webhookId}/events
Scope: webhook-events-read
```
Paginated log of every delivery attempt — status code, payload, timestamp. Use for debugging.
***
## Checklist ✅
* [ ] HTTPS endpoint deployed
* [ ] Challenge handler implemented (GET with `?challenge=`)
* [ ] Event handler implemented (POST with `X-Event` header)
* [ ] Respond 200 fast, process async
* [ ] `secret` stored securely
* [ ] Subscribed to the right event flags
* [ ] Test event sent and received
# Handling Transaction States
Source: https://developers.opencard.io/ems/webhooks/transactions
Handle OpenCard transaction state webhooks — what authorized, cleared, deleted, and invoiced mean, when amounts settle, and how to post them to a ledger.
Getting transaction states wrong = angry accountants. Here's the exact logic.
***
## The 4 states
```mermaid theme={null}
stateDiagram-v2
[*] --> authorized: purchase happens
authorized --> deleted: auth reversed
authorized --> cleared: settles (3-5 days)
cleared --> invoiced: issuer invoices
```
| State | Timing | Amounts reliable? | Use for accounting? |
| - | - | - | - |
| `authorized` | Seconds after purchase | ❌ May change | Preview only |
| `deleted` | Before clearing | N/A | Delete from your system |
| `cleared` | 3-5 days after purchase | ✅ Final | **YES** |
| `invoiced` | When issuer invoices | ✅ Final + invoice # | YES |
***
## Recommended implementation
```javascript theme={null}
async function handleTransaction(event, payload) {
const txId = payload.id;
const state = payload.state; // from X-Event suffix or payload.state
switch (event) {
case 'card.transaction.authorized':
// Create if not exists (upsert by id)
await db.transactions.upsert(txId, { ...payload, status: 'pending' });
break;
case 'card.transaction.cleared':
case 'card.transaction.invoiced':
// Update existing OR create if you skipped authorized
await db.transactions.upsert(txId, { ...payload, status: 'confirmed' });
break;
case 'card.transaction.deleted':
// Only relevant if you stored authorized
await db.transactions.delete(txId);
break;
}
}
```
### The golden rules
1. **Always handle `cleared`** — non-negotiable for accounting
2. **`authorized` + `deleted` + `cleared`** = best UX (users see purchases instantly, deletions handled)
3. **`authorized` alone** = you'll miss transactions that clear without prior auth (rare but possible)
4. **Use `id` as primary key** — same `id` across all states for one transaction
5. **`card_issuer_reference` differs per state** — don't use it as your primary key; when merging with another feed, compare cleared-to-cleared only → [Current card issuer integration](/ems/current-card-issuer-integration)
***
## Current card issuer integration
If your client already receives transactions from the same card program through another integration, you can run both feeds at once. OpenCard is often added while the old feed is still active — you do not need to wait for it to be switched off.
* Start the OpenCard feed per card holder on **`card_holder.identified`** (includes a retroactive batch).
* **Upsert on transaction `id`** for OpenCard events.
* On **`card.transaction.cleared`**, deduplicate against your other store with **`card_issuer_reference`** — cleared matches cleared only; auth and cleared references may differ per issuer.
Full guidance → [Current card issuer integration](/ems/current-card-issuer-integration)
## Upsert logic detail
| Event | Transaction exists? | Action |
| - | - | - |
| `authorized` | No | Create |
| `authorized` | Yes | Update (amounts may change before clearing) |
| `cleared` | No | Create (you missed authorized — still ok) |
| `cleared` | Yes | Update with final amounts |
| `invoiced` | No | Create |
| `invoiced` | Yes | Update, set `invoice_number` |
| `deleted` | Yes | Delete |
| `deleted` | No | Ignore (nothing to delete) |
***
## `receiptable` flag
```json theme={null}
{ "receiptable": true }
```
If `true`, a digital receipt exists for this transaction at one of OpenCard's Receipt provider partners — expect `receipt.fetched` (and often TrueVAT / line items) shortly after. If `false`, don't wait for a digital receipt.
Subscribe to `receipt_fetched`, `transaction_true_vat`, and `transaction_line_items` separately — they come as independent events after the transaction.
***
## Card types
| `card_type` | Meaning |
| - | - |
| `corporate` | Corporate card with invoice to the company |
| `corporate_with_private_invoice` | Corporate card with invoice to the card holder |
| `private` | Private card with invoice to the card holder |
## Card funding
| `card_funding` | Meaning |
| - | - |
| `credit` | Credit card |
| `debit` | Debit card |
Present on **all** `card.transaction.*` states — same value for a given card across authorized / cleared / deleted / invoiced.
Which of these you receive is set on the organization with `process_card_type_*`. Enable all three when you create the organization, then turn a type off if you do not want those transactions. → [Organization setup — card types](/ems/organization-setup#card-types)
***
## VAT caveat
`vat_rate` and `vat_amount` on the transaction payload come from the **card network** — often wrong for restaurants, hotels, etc.
For correct VAT, subscribe to `transaction.true.vat` — that comes from the actual merchant receipt.
# Authentication
Source: https://developers.opencard.io/getting-started/authentication
Set up OAuth2 account_client machine-to-machine credentials for your EMS integration, request bearer tokens, and pick the right scopes for OpenCard endpoints.
EMS integrations authenticate with an **`account_client`** OAuth client — machine-to-machine credentials tied to your partner account.
This docs site covers **account\_client** only. Other client types (`organization_client`, `account_user`, etc.) exist internally but are not part of the EMS integration path.
***
## Get a token
```
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
Accept: application/json
```
```bash theme={null}
curl -X POST https://sandbox-api.opencard.io/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "Accept: application/json" \
-d "grant_type=client_credentials" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "scope=organizations-read organizations-write webhooks-write"
```
**Response:**
```json theme={null}
{
"token_type": "Bearer",
"expires_in": 31536000,
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGc..."
}
```
Use on every API call:
```
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGc...
Accept: application/json
```
Always send `Accept: application/json` on Application API and OAuth requests. Without it, auth failures may return an HTML login page instead of a JSON error body.
***
## Create an account\_client
**Dashboard:** Account → OAuth Clients → Create New
Or via API (requires an existing client with `oauth-clients-write`):
```
POST /accounts/{accountId}/oauthclients
```
Response includes `secret` in **plain text** — shown once. Store it immediately.
Update scopes later:
```
PUT /accounts/{accountId}/oauthclients/{oauthclientId}
{ "scopes": ["organizations-read", "organizations-write", "webhooks-write"] }
```
Clients created on an account **without** an `organization_id` are `account_client` — this is what you want.
***
## Recommended scopes for EMS
Start with this set:
```
account-tpas-read account-tpas-write account-tpa-signatories-write account-tpa-identities-read
organizations-read organizations-write
card-holders-read card-holders-write
webhooks-read webhooks-write webhook-events-read
public-records-read
payment-products-read account-payment-products-read account-payment-products-write
oauth-clients-read
billings-write
receipts-write
```
Request only what you need. Scopes are space-separated in the token request.
***
## All account\_client scopes
| Scope | What it unlocks |
| - | - |
| `accounts-read` / `accounts-write` | Your account settings |
| `oauth-clients-read` / `oauth-clients-write` | Manage OAuth clients |
| `public-records-read` | Company registry lookup |
| `account-tpas-read` / `account-tpas-write` / `account-tpas-delete` | TPAs |
| `account-tpa-signatories-read` / `-write` / `-delete` | TPA signatories |
| `account-tpa-identities-read` | Identities on a TPA |
| `payment-products-read` | List available payment products |
| `account-payment-products-read` / `-write` | List, enable, and disable payment products on your account |
| `organizations-read` / `-write` / `-delete` | Organizations |
| `card-holders-read` / `-write` / `-delete` | Card holders |
| `webhooks-read` / `webhooks-write` | Webhooks |
| `webhook-events-read` | Webhook delivery log |
| `webhook-headers-read` / `-write` / `-delete` | Custom webhook headers |
| `webhook-groups-read` / `-write` / `-delete` | Buyer-party webhook filters |
| `billings-read` / `billings-write` | Billing profiles |
| `receipts-write` | Receipt OCR scan |
| `files-read` | Download files (e.g. receipt URLs) |
| `environmental-impacts-read` | CO₂ data on transactions |
| `account-users-read` / `-write` / `-delete` | Account user management |
| `account-statistics-read` | Account statistics |
***
## Access control
Your `account_client` token is tied to your **account**. The API checks:
1. Token has the required scope for the route
2. Token owner has access to `accountId` / `organizationId` in the URL
You can only access your own account's resources.
***
## Errors
| Status | Meaning |
| - | - |
| `401` | Invalid or expired token |
| `403` | Valid token but missing scope or no access to resource |
| `404` | Resource doesn't exist (or you can't see it) |
# Quickstart — Zero to First Webhook
Source: https://developers.opencard.io/getting-started/quickstart
End-to-end OpenCard EMS quickstart in the right order — register, billing, TPA, organization, webhook, TPA signed, card holder, first transaction.
This is the full path for **one test client**, in the order the API expects it:
```
account → payment product → billing → TPA + signatories → organization → webhook → tpa.signed → card holder → transaction
```
Every step produces an **id** that the next step needs. Don't skip ahead — the two most common integration failures are an organization without `billing_id` and card holders created before the webhook exists. Both are avoided by simply following the order below.
Grab coffee. ☕ Let's go.
***
## Step 0: Register (sandbox)
👉 [sandbox-api.opencard.io/register](https://sandbox-api.opencard.io/register)
You get an **Account** + admin user. Verify email. Log in.
Your `accountId` is in the dashboard URL or API responses.
***
## Step 1: Create an account\_client and get a token
```bash theme={null}
# Dashboard: Account → OAuth Clients → Create
# This is an account_client — machine-to-machine credential for your EMS integration.
curl -X POST https://sandbox-api.opencard.io/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "Accept: application/json" \
-d "grant_type=client_credentials" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "scope=billings-write account-tpas-write account-tpas-read account-tpa-signatories-write organizations-write organizations-read card-holders-write webhooks-write webhooks-read public-records-read payment-products-read account-payment-products-read account-payment-products-write"
```
Save the `access_token`. All subsequent calls:
```
Authorization: Bearer {access_token}
Accept: application/json
```
Scopes are space-separated. Request only what you need. Full list in [Authentication](/getting-started/authentication). Always send `Accept: application/json` so error responses stay JSON (not an HTML login page).
**Verify:** `GET /api/v1/application/payment-products` returns `200`. Auth works.
***
## Step 2: Enable a payment product (once per account)
```bash theme={null}
# List available payment products
curl https://sandbox-api.opencard.io/api/v1/application/payment-products \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json"
# Enable one on your account (use the id from above)
curl -X POST https://sandbox-api.opencard.io/api/v1/application/accounts/$ACCOUNT_ID/payment-products/1 \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json"
```
Save `payment_product_id`. You need it for the TPA in step 4.
→ [Payment product setup](/ems/payment-products-setup)
***
## Step 3: Create a billing profile 🧾
The billing profile is the client's invoice profile **and** the switch that turns on products. The `product_*` flags decide which webhook events the client is allowed to subscribe to — so this must exist before the organization and the webhook.
```bash theme={null}
curl -X POST https://sandbox-api.opencard.io/api/v1/application/accounts/$ACCOUNT_ID/billings \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"name_display": "Acme AB",
"name_legal": "Acme AB",
"organization_number": "5561234567",
"country": "SE",
"email_invoice": "finance@acme.se",
"your_reference_invoice": "PO-12345",
"product_transaction": true,
"product_digital_receipt": false,
"product_aland_index": false
}'
```
Response → save `id` as **`billing_id`**.
`product_transaction: true` is what allows `card_transaction_*` events on the webhook later. Creating a webhook with transaction events for an organization whose billing lacks this flag (or that has no `billing_id` at all) fails.
→ [Model: Billing](/ems/model/billing)
***
## Step 4: Create the TPA 📜
TPA = Transaction Processing Authorisation. Legal permission for the client's transaction data to flow.
First, check who may sign for the company (optional, but saves a round-trip with the client):
```bash theme={null}
curl "https://sandbox-api.opencard.io/api/v1/application/accounts/$ACCOUNT_ID/publicrecords?country=SE&organization_number=5561234567" \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json"
```
Response includes `signature_combinations` — the people you'll add as signatories in step 5.
Then create the TPA:
```bash theme={null}
curl -X POST https://sandbox-api.opencard.io/api/v1/application/accounts/$ACCOUNT_ID/tpas \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"payment_product_id": 1,
"name": "Acme AB",
"country": "SE",
"organization_number": "5561234567",
"language": "sv"
}'
```
**Org number formats:**
| Country | Format | Example |
| - | - | - |
| 🇸🇪 SE | 10 digits | `5561234567` |
| 🇳🇴 NO | 9 digits | `123456789` |
| 🇩🇰 DK | 8 digits | `12345678` |
| 🇫🇮 FI | `XXXXXXX-X` | `1234567-8` |
Response → save `id` as **`tpa_id`**.
→ [TPA flow](/ems/tpa-flow)
***
## Step 5: Add signatories → email goes out 📧
```bash theme={null}
curl -X POST https://sandbox-api.opencard.io/api/v1/application/accounts/$ACCOUNT_ID/tpas/$TPA_ID/signatories \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"email": "ceo@acme.se",
"name": "Anna Andersson"
}'
```
The email is queued immediately with a signing link. Repeat for each signatory required by `signature_combinations`.
Signatories can only be updated/deleted while `signed=false`. Once signed, they're locked.
Don't wait for the signature here — continue with steps 6 and 7 right away so the webhook is in place when `tpa.signed` fires.
***
## Step 6: Create the organization 🏢
The organization is the runtime container for the client. It links the TPA (legal) and the billing (products) together.
```bash theme={null}
curl -X POST https://sandbox-api.opencard.io/api/v1/application/accounts/$ACCOUNT_ID/organizations \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"reference_id": "client_acme_001",
"tpa_id": '"$TPA_ID"',
"billing_id": '"$BILLING_ID"',
"name": "Acme AB"
}'
```
`reference_id` = **your** internal client ID. It comes back in every webhook.
Response → save `id` as **`organization_id`**.
→ [Model: Organization](/ems/model/organization)
***
## Step 7: Create the webhook 🔔
```bash theme={null}
curl -X POST https://sandbox-api.opencard.io/api/v1/application/accounts/$ACCOUNT_ID/organizations/$ORG_ID/webhooks \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"url": "https://your-app.com/webhooks/opencard",
"enabled": true,
"tpa_signed": true,
"card_holder_created": true,
"card_holder_identified": true,
"card_holder_signed_pdpc": true,
"card_transaction_authorized": true,
"card_transaction_cleared": true,
"card_transaction_deleted": true
}'
```
**Challenge happens immediately.** OpenCard sends:
```
GET https://your-app.com/webhooks/opencard?challenge=RANDOM_STRING
Header: X-Event: challenge
```
Your endpoint must respond with header:
```
X-Verify-Token: HMAC-SHA256(challenge, webhook_secret)
```
**Verify:** `GET .../organizations/$ORG_ID/webhooks` shows `active: true`. Until then no events are delivered.
The webhook is not optional. Card holder lifecycle and PDPC signing write events to it — an organization without a webhook cannot complete PDPC signing. Create it now, before any card holder.
→ [Webhook setup](/ems/webhooks/setup)
***
## Step 8: TPA gets signed → your first webhook ✍️
The signatory clicks the link from step 5, reads the TPA and signs with eID. When every required signatory has signed:
* Signed PDF is generated and stored
* **`tpa.signed` is POSTed to your webhook** — this is your first real event
* TPA status → `pending-activation`, then `activated` once confirmed
**Verify:** you received `X-Event: tpa.signed` with `organization.reference_id = client_acme_001`.
→ [eID signing](/ems/eid-signing) · [Event reference](/ems/webhooks/events)
***
## Step 9: Create card holders 👤
Two paths — pick one. Full guide → [Card holder onboarding](/ems/card-holders).
### Path A: Email + eID (new employee)
```bash theme={null}
curl -X POST https://sandbox-api.opencard.io/api/v1/application/accounts/$ACCOUNT_ID/organizations/$ORG_ID/cardholders \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"reference_id": "employee_john_42",
"email": "john@acme.se"
}'
```
John gets an email → signs the PDPC with eID → `card_holder.identified` → transactions flow.
`language` is optional. If omitted, OpenCard picks the PDPC language from the TPA country and falls back to English where no translation exists.
### Path B: `identity_id` (instant — person already in OpenCard)
```bash theme={null}
# 1. List identities on the TPA (people who already have cards)
curl "https://sandbox-api.opencard.io/api/v1/application/accounts/$ACCOUNT_ID/tpas/$TPA_ID/identities?is_card_holder=false" \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json"
# 2. Create card holder with identity_id — transactions start immediately
curl -X POST https://sandbox-api.opencard.io/api/v1/application/accounts/$ACCOUNT_ID/organizations/$ORG_ID/cardholders \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{
"reference_id": "employee_anna_07",
"identity_id": 123,
"skip_pdpc_email": true
}'
```
`card_holder.identified` fires instantly + retroactive transaction webhooks replay. ⚡
**Verify:** you received `X-Event: card_holder.identified`.
***
## Step 10: Receive your first transaction 🎉
Once the issuer delivers transaction states for an identified card holder:
```json theme={null}
// POST to your webhook URL
// Header: X-Event: card.transaction.authorized
{
"id": "10",
"state": "authorized",
"receiptable": true,
"card_issuer": "nordea",
"payment_product": {
"id": 1,
"name": "Acme Corporate Card"
},
"original_amount": 109.38,
"original_currency": "SEK",
"purchase_merchant": "Coop Svängsta",
"purchase_date": "2022-09-02",
"card_funding": "credit",
"card_holder": { "reference_id": "employee_john_42" },
"organization": { "reference_id": "client_acme_001" }
}
```
Nothing arriving yet? Fire a test event: `POST .../webhooks/{webhookId}/test/card.transaction.authorized`.
**You did it.** From zero to live transaction data.
***
## Recap — what you stored
| Variable | From step | Used in |
| - | - | - |
| `ACCOUNT_ID` | 0 | everything |
| `TOKEN` | 1 | everything |
| `payment_product_id` | 2 | TPA |
| `BILLING_ID` | 3 | organization |
| `TPA_ID` | 4 | signatories, organization |
| `ORG_ID` | 6 | webhook, card holders |
Repeat steps 3–9 for every new client. Steps 0–2 are once per account.
***
## What's next?
* [Customer onboarding](/ems/customer-onboarding) — the same flow as a production integration guide, with API reference links
* [Transaction states](/ems/webhooks/transactions) — how to handle authorized vs cleared vs deleted
* [All webhook events](/ems/webhooks/events) — full payload reference
* [Receipt enrichment](/ems/receipts) — digital receipts + true VAT
# Register & Setup
Source: https://developers.opencard.io/getting-started/register
Register a sandbox account on OpenCard, verify your email, find your accountId, and create your first account_client OAuth client to start integrating.
## Sandbox registration
1. Go to 👉 [sandbox-api.opencard.io/register](https://sandbox-api.opencard.io/register)
2. Fill in account + user details
3. Verify email (check inbox + spam)
4. Log in
You now have:
* An **Account** (your EMS partner account)
* An **admin user** (for dashboard access)
* An `accountId` (visible in dashboard URL)
***
## Create account\_client
**Dashboard:** Account → OAuth Clients → Create New
This creates an **`account_client`** — your machine-to-machine credential for the EMS API. Give it a name, select scopes (see [Authentication](/getting-started/authentication)), save.
You'll get:
* `client_id`
* `client_secret` (shown **once** — copy it now)
***
## Get your first token
```bash theme={null}
curl -X POST https://sandbox-api.opencard.io/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "Accept: application/json" \
-d "grant_type=client_credentials" \
-d "client_id=YOUR_ID" \
-d "client_secret=YOUR_SECRET" \
-d "scope=accounts-read"
```
If you get a token back → you're wired up. 🔌
***
## Production access
Sandbox is free and instant. Production requires:
1. Complete sandbox integration
2. Security review
3. Partnership agreement
Email **[support@opencard.io](mailto:support@opencard.io)** when you're ready.
***
## What's next?
→ [Quickstart](/getting-started/quickstart) — full integration walkthrough
# Environments
Source: https://developers.opencard.io/introduction/environments
Compare OpenCard sandbox and production environments — API base URLs, OAuth token endpoints, signing pages, and registration links for each isolated universe.
| | 🧪 Sandbox | 🚀 Production |
| - | - | - |
| **API base** | `https://sandbox-api.opencard.io` | `https://api.opencard.io` |
| **Application API** | `.../api/v1/application` | `.../api/v1/application` |
| **OAuth token** | `.../oauth/token` | `.../oauth/token` |
| **Register** | [sandbox-api.opencard.io/register](https://sandbox-api.opencard.io/register) | [api.opencard.io/register](https://api.opencard.io/register) |
| **Signing pages** | `sandbox-api.opencard.io/accounts/.../sign/...` | `api.opencard.io/accounts/.../sign/...` |
Everything is isolated — credentials, data, webhook secrets, signed documents. Separate universes.
# Glossary
Source: https://developers.opencard.io/introduction/glossary
OpenCard glossary — EMS, account, organization, billing, card holder, identity, TPA, PDPC, transaction state, card issuer, receipt provider, webhook defined.
| Term | What it means |
| - | - |
| **EMS** | Expense Management System — software for corporate card spend, expenses, and receipts. |
| **Account** | Your Expense Management System partner account. Top of the hierarchy. |
| **Organization** | Your end-client. You set `reference_id`. Has webhooks + card holders. |
| **Billing** | Invoice recipient + roll-up. Organizations with the same `billing_id` appear as **one line** on the invoice you receive from OpenCard. |
| **Card Holder** | Individual whose transactions you receive. You set `reference_id`. |
| **Identity** | Physical person in OpenCard (SSN via eID). Has cards. Use `identity_id` for instant onboarding. |
| **TPA** | Transaction Processing Authorization. Company-level legal agreement. |
| **PDPC** | Personal Data Processing Consent. Individual GDPR consent. |
| **Transaction** | One purchase. ID stays same across all states. |
| **Transaction State** | Snapshot: `authorized`, `cleared`, `deleted`, `invoiced`. |
| **Payment product** | The card a client activates (debit or credit under a payment program). Enable products on your account, then pass the product `id` as `payment_product_id` on the TPA. |
| **Card Issuer** | Bank or card program. Delivers transactions via the Issuer API; may also use `receipts.opencard.io` for digital receipt matching. |
| **Receipt Provider** | Collects receipts from merchants and delivers them to OpenCard. OpenCard forwards enriched data to Expense Management Systems. |
| **Webhook** | HTTP POST from OpenCard when stuff happens. |
| **Reference ID** | Your string ID on orgs/cardholders. In every webhook. |
| **Challenge** | Webhook verification handshake on setup. |
| **Scope** | OAuth permission string like `webhooks-write`. |
| **CAID / MID** | Merchant ID in receipt networks. |
| **MCC** | Merchant Category Code (ISO 18245). |
# How OpenCard works
Source: https://developers.opencard.io/introduction/how-it-works
The OpenCard hierarchy — account, billing, organization, card holder, transaction, TPA — and how events flow from card issuer to your EMS webhook endpoint.
## The hierarchy (memorize this)
```
Account ← you (the EMS partner)
├── Billing ← invoice recipient; orgs with same billing_id = one line on YOUR OpenCard invoice
└── Organization ← your end-client company
├── billing_id → which billing profile (invoice line roll-up) this org belongs to
├── Webhook ← where events get POSTed
├── CardHolder ← employee whose card data you want
│ └── Card ← actual credit card (managed by issuer)
│ └── Transaction
│ └── TransactionState (authorized → cleared → invoiced)
└── TPA ← legal agreement (signed by client's authorized signatories)
```
**Account** = your OpenCard partner account. One per EMS.
**Billing** = invoice recipient for **your** OpenCard bill. Organizations that share a `billing_id` are rolled up as **one line** on that invoice (not separate invoices per org). Example: three orgs with `billing_id: 1` → one combined line to your finance team. → [Billing](/ems/model/billing)
**Organization** = one of your customers. You set `reference_id` to your internal client ID. Most EMSs map 1:1 (one org per client), but you can split one client into multiple orgs if you need different webhook configs or card type filters. → [Organization](/ems/model/organization)
**Card Holder** = a person. You set `reference_id` to your internal user ID. Two ways to onboard: **email** (user signs with eID) or **`identity_id`** (instant, person already in OpenCard). → [Card holder](/ems/model/card-holder) · [Onboarding](/ems/card-holders)
**TPA** (Transaction Processing Authorization) = legal doc the client's signatories sign saying "yes, our card data can flow to this EMS via OpenCard." → [TPA](/ems/model/tpa)
**PDPC** (Personal Data Processing Consent) = GDPR consent the individual cardholder signs saying "yes, my transactions can be shared."
## Two legal docs, two audiences
| Doc | Who signs | How | Purpose |
| - | - | - | - |
| **TPA** | Company's authorized signatories (CEO, board, etc.) | eID **sign** mode — they sign the actual legal document | Company-level permission |
| **PDPC** | Individual cardholder (employee) | eID **auth** mode — identity verification + consent checkbox | Person-level GDPR consent |
OpenCard handles all of this. For new users you provide an email — we send the link, they sign with eID. For users already in OpenCard you pass `identity_id` and skip the wait entirely.
## The full onboarding sequence
```mermaid theme={null}
flowchart TD
A[1. Register account] --> B[2. Create OAuth client]
B --> C[3. Enable payment products on account]
C --> D[4. Create billing profile]
D --> E[5. Create TPA for client]
E --> F[6. Add TPA signatories]
F --> G[7. They sign with eID]
G --> K[TPA fully signed → tpa.signed webhook]
K --> L[8. Create organization + billing_id]
L --> M[9. Configure webhook]
M --> N[10. Create card holders]
N --> O[11. Cardholders get PDPC email]
O --> P[12. They sign PDPC with eID]
P --> Q[13. Transactions start flowing]
```
Step-by-step for EMS integrators (APIs, phases, checklist) → [Customer onboarding](/ems/customer-onboarding)
## Runtime: what happens on a purchase
1. **Cardholder buys coffee** ☕
2. **Issuer** sends `authorized` transaction state to OpenCard
3. OpenCard normalizes the data, checks org/webhook config
4. **Your webhook** gets `card.transaction.authorized` within seconds
5. If merchant is receipt-enabled → OpenCard requests receipt match
6. 3–5 days later issuer sends `cleared` state
7. Your webhook gets `card.transaction.cleared` — **this is the accounting truth**
8. Maybe `receipt.fetched`, `transaction.true.vat`, `transaction.line_items` follow
## What you build vs what OpenCard builds
| You build | OpenCard builds |
| - | - |
| Webhook endpoint + event handlers | Legal signing flows (TPA + PDPC) |
| Org/cardholder CRUD in your UI | eID signing flows |
| Map `reference_id` to your DB | Issuer communication |
| Transaction UI in your expense app | Receipt matching orchestration |
| | Webhook delivery + retry + logs |
## Reference IDs — your best friend
Every organization and card holder has a `reference_id` **you define**. Put your internal ID there. Every webhook payload includes it. Zero lookup tables needed.
```json theme={null}
{
"card_holder": { "reference_id": "user_48291" },
"organization": { "reference_id": "client_acme_corp" }
}
```
Map straight to your DB. Done. 🎯
## Client already has another transaction feed?
Common during rollout. Your client enables OpenCard with the issuer — they do not have to disable an existing connection first, and you are not responsible for that. If they want one feed only, the client coordinates with the issuer.
OpenCard sends **`authorized`** events (many other integrations do not) and replays history on **`card_holder.identified`**. Deduplicate **`cleared`** events with `card_issuer_reference` when merging with your existing store.
→ [Current card issuer integration](/ems/current-card-issuer-integration)
# OpenCard Developers
Source: https://developers.opencard.io/introduction/index
OpenCard developer hub — real-time card transactions, digital receipts, VAT, and webhooks for Expense Management Systems, card issuers, and receipt providers.
# Build on OpenCard
Real-time card transactions, digital receipts, VAT, and line items — delivered to your systems via webhooks.
Orgs, card holders, webhooks. Receive `card.transaction.*` events in real time.
Full API on `api.opencard.io` — TPAs, organizations, card holders, webhooks.
Deliver transactions to EMS apps, or fetch digital receipts via `receipts.opencard.io`.
Collect merchant receipts and deliver them to OpenCard for Expense Management System enrichment.
Explore further
Data model, event flow, and what OpenCard handles.
OAuth2 account\_client credentials and scopes.
Sandbox vs production URLs.
Transactions, receipts, VAT, lifecycle events.
`receipts.opencard.io` — publishers and callback requests.
TPA, PDPC, card holder — every term explained.
# About OpenCard
Source: https://developers.opencard.io/introduction/welcome
What OpenCard is, who uses it — EMS, card issuers, receipt providers — and what transaction, receipt, VAT, and environmental data lands in your webhooks.
OpenCard is the pipe between card programs and your app — transactions, receipts, VAT, line items, and environmental impact, pushed to your webhook as stuff happens.
No polling. No scraping PDFs.
## Who are you?
| You are... | Go here | What you do |
| - | - | - |
| 🏢 **Expense Management System** | [Quickstart](/getting-started/quickstart) | Create orgs, card holders, webhooks. Receive transaction events. |
| 💳 **Card issuer / bank** | [Card Issuers](/card-issuers) | Deliver transactions via the Issuer API, or fetch digital receipts via `receipts.opencard.io`. |
| 🧾 **Receipt provider** | [Receipt Providers](/receipt-providers) | Collect receipts, deliver to OpenCard for Expense Management System enrichment. |
## The 30-second version
```mermaid theme={null}
sequenceDiagram
participant Bank as Card Program
participant OC as OpenCard
participant You as Your Expense Management System
Note over You,OC: Setup (once per client)
You->>OC: Create billing profile (invoice recipient + line roll-up)
You->>OC: Create TPA + signatories sign with eID
You->>OC: Create organization + webhook (linked to billing)
You->>OC: Create card holders
Note over Bank,You: Runtime (every purchase)
Bank->>OC: transaction data
OC->>You: webhook card.transaction.*
```
## What you get pushed to your webhook
* `card.transaction.authorized` — purchase just happened
* `card.transaction.cleared` — settled, use for accounting
* `card.transaction.deleted` — auth reversed
* `card.transaction.invoiced` — has invoice number
* `receipt.fetched` — digital receipt URL
* `transaction.true.vat` — correct VAT from merchant
* `transaction.line.items` — purchase line items
* `card_holder.*` — lifecycle events
* `tpa.signed` — legal agreement done
## Next step
→ **[Quickstart](/getting-started/quickstart)** — full walkthrough from register to first webhook.
# Callback Delivery
Source: https://developers.opencard.io/receipt-providers/callback-delivery
How receipt providers POST matched receipts to OpenCard — endpoint URL, X-Event headers, HMAC-SHA256 signatures, and payload structure for callback delivery.
When you've matched a receipt from your collection to an OpenCard transaction, POST it here:
```
POST https://api.opencard.io/api/v1/service/marcet/callback/{referenceId}
```
`{referenceId}` = OpenCard transaction reference from the match request.
***
## Required headers
| Header | Value |
| - | - |
| `X-Event` | `CallbackRequestResolved` or `CallbackRequestDeleted` |
| `X-CallbackRequest-Id` | Callback request ID from OpenCard |
| `X-Data-Signature` | `HMAC-SHA256(raw_body, client_secret)` |
***
## Signature calculation
```python theme={null}
import hmac, hashlib
signature = hmac.new(
client_secret.encode(),
request_body_bytes,
hashlib.sha256
).hexdigest()
```
```javascript theme={null}
const crypto = require('crypto');
const sig = crypto.createHmac('sha256', clientSecret)
.update(rawBody)
.digest('hex');
```
Must match `X-Data-Signature` header exactly. Raw body bytes, not re-serialized JSON.
***
## Resolved payload
Content type depends on callback type configured for the request:
* `application/xml` — receipt XML (parsed for VAT + line items)
* `application/json` — structured receipt data
OpenCard extracts:
* True VAT rates and amounts → `transaction.true.vat` webhook
* Line items → `transaction.line.items` webhook
* Receipt file → stored, URL sent via `receipt.fetched` webhook
***
## Response
| Code | Meaning |
| - | - |
| `200` | Accepted |
| `403` | Bad signature |
| `404` | Unknown callback request ID |
Duplicate `CallbackRequestResolved` for already-resolved requests → `200` (idempotent, logged as warning).
***
## What happens after
1. Receipt file downloaded and stored in cloud
2. TrueVAT records created
3. LineItem records created
4. Webhooks fired to EMS (per org webhook config)
# How receipt providers work with OpenCard
Source: https://developers.opencard.io/receipt-providers/how-it-works
How receipt providers fit into OpenCard — collect receipts from merchants, handle match requests, deliver receipt callbacks, and enrich EMS transaction data.
You operate a **receipt collection network**. OpenCard connects your receipts to card transactions and passes enriched data to EMS integrators.
***
## Where you sit
```mermaid theme={null}
flowchart LR
Merchants[Merchants / POS / E-com] -->|receipts| You[Receipt Provider]
OC[OpenCard] -->|match request| You
You -->|receipt callback| OC
OC -->|webhooks| EMS[EMS apps]
```
1. You continuously **collect receipts** from merchants in your network
2. OpenCard receives a card transaction marked `receiptable: true`
3. OpenCard sends you the transaction details to match against your receipts
4. When you find a match, you **POST the receipt** to OpenCard
5. OpenCard stores the receipt, extracts VAT + line items
6. EMS apps receive `receipt.fetched`, `transaction.true.vat`, and `transaction.line.items` webhooks
You only integrate with OpenCard — not with EMS apps or card programs.
***
## What you deliver to OpenCard
| You send | OpenCard does | EMS receives |
| - | - | - |
| Receipt file (PDF, image, XML) | Stores file, generates URL | `receipt.fetched` |
| VAT breakdown in receipt | Creates TrueVAT records | `transaction.true.vat` |
| Line items in receipt | Creates LineItem records | `transaction.line.items` |
***
## Onboarding checklist
* [ ] Receive **client secret** from OpenCard
* [ ] Endpoint ready to **receive match requests** from OpenCard
* [ ] Implement **outbound callback** to OpenCard (`POST /v1/service/marcet/callback/{referenceId}`)
* [ ] Handle `CallbackRequestResolved` and `CallbackRequestDeleted`
* [ ] Sign every payload with HMAC-SHA256
→ [Callback delivery](/receipt-providers/callback-delivery) for endpoint details.
Contact **[support@opencard.io](mailto:support@opencard.io)** to get started.
# Receipt Provider Integration
Source: https://developers.opencard.io/receipt-providers/index
Integration guide for receipt providers — collect receipts from merchants and POS, deliver to OpenCard, and enrich EMS transactions with VAT and line items.
Receipt providers are actors that **collect digital receipts** — from merchants, POS systems, e-commerce platforms, or your own network — and **deliver them to OpenCard**.
OpenCard attaches the receipt to the matching card transaction, extracts VAT and line items, and forwards everything to EMS apps via webhooks.
***
## The flow
```mermaid theme={null}
sequenceDiagram
participant RP as Receipt Provider
participant OC as OpenCard
participant EMS as EMS
Note over RP: You collect receipts from merchants
OC->>RP: Match request (transaction data)
RP->>RP: Find receipt in your collection
RP->>OC: POST callback with receipt data
OC->>EMS: receipt.fetched
OC->>EMS: transaction.true.vat
OC->>EMS: transaction.line.items
```
Your job ends at the callback to OpenCard. You never talk to EMS apps directly.
***
## What you implement
1. **Collect receipts** in your merchant network
2. **Receive match requests** from OpenCard when a transaction is receiptable
3. **POST the receipt** back to OpenCard when you have a match
→ [How it works](/receipt-providers/how-it-works) · [Callback delivery](/receipt-providers/callback-delivery) · [Payload formats](/receipt-providers/payload-formats)
***
## Auth
Callbacks to OpenCard are authenticated with **HMAC-SHA256**:
```
X-Data-Signature: HMAC-SHA256(raw_request_body, shared_client_secret)
```
You receive the shared secret from OpenCard during onboarding.
***
## Events
| `X-Event` header | Meaning |
| - | - |
| `CallbackRequestResolved` | Receipt found — send receipt payload |
| `CallbackRequestDeleted` | No match / expired — send empty body |
Contact **[support@opencard.io](mailto:support@opencard.io)** to become a receipt provider partner.
# Payload Formats
Source: https://developers.opencard.io/receipt-providers/payload-formats
Payload formats for receipt provider callbacks to OpenCard — required headers, HMAC signatures, and XML receipt body for resolved and deleted callback events.
When you send a collected receipt to OpenCard, the body format depends on the callback type configured for your integration.
***
## Headers (always required)
| Header | Value |
| - | - |
| `X-Event` | `CallbackRequestResolved` or `CallbackRequestDeleted` |
| `X-CallbackRequest-Id` | Callback request ID from OpenCard |
| `X-Data-Signature` | `HMAC-SHA256(raw_body, client_secret)` |
***
## CallbackRequestResolved — XML receipt
Content-Type: `application/xml`
OpenCard parses receipt XML to extract:
* True VAT rates and amounts
* Line items (if present in receipt)
* Receipt file for storage
Delivery note XML (partner code transactions) uses a different VAT extraction path.
***
## CallbackRequestResolved — JSON
Content-Type: `application/json`
Structured receipt data when callback type is JSON.
***
## CallbackRequestDeleted
Empty or minimal body. Indicates the match was cancelled or expired. OpenCard marks the callback request as deleted.
***
## Signature calculation
```python theme={null}
import hmac, hashlib
signature = hmac.new(
client_secret.encode(),
request_body_bytes, # raw bytes, not re-serialized
hashlib.sha256
).hexdigest()
# Set as X-Data-Signature header
```
```javascript theme={null}
const crypto = require('crypto');
const sig = crypto.createHmac('sha256', clientSecret)
.update(rawBodyBuffer)
.digest('hex');
```
Hash the **raw request body bytes** exactly as received. Re-serializing JSON will break the signature.
***
## After successful delivery
OpenCard fires these webhooks to the EMS (if subscribed):
**`receipt.fetched`**
```json theme={null}
{
"url": "https://api.opencard.io/files/receipts/abc123",
"content_type": "application/pdf",
"expiration_time": "2022-10-02 11:12:14",
"purchase_merchant": "Coop",
"original_amount": 109.38,
"original_currency": "SEK",
"transaction": { "id": "10", "card_issuer": "nordea" },
"payment_product": {
"id": 1,
"name": "Acme Corporate Card"
}
}
```
**`transaction.true.vat`** — corrected VAT from merchant receipt
**`transaction.line.items`** — purchase line items
OpenCard handles EMS delivery — you only send receipts to OpenCard.