# Gift cards

<https://kuvert.dk/en/dokumentation/api/giftcards>

Eight endpoints cover the card's entire life through the API: issue, list, look up, redeem, reverse, resend, cancel and refund.

- `POST /v1/api/giftcards`
- `GET /v1/api/giftcards`
- `GET /v1/api/giftcards/{code}`
- `POST /v1/api/giftcards/{code}/redeem`
- `POST /v1/api/redemptions/{id}/reverse`
- `POST /v1/api/giftcards/{code}/resend`
- `POST /v1/api/giftcards/{code}/cancel`
- `POST /v1/api/orders/{id}/refund`

Cancel cannot be undone. There is no reactivation, because a card that was killed and later quietly revived is an obligation nobody has agreed to. Refund is the only endpoint that moves money: Stripe sends the amount back to the card that paid, and nowhere else.

## Try it here on the page

The console below runs the same schemas and the same redemption rule as the API, against a card that exists only in your browser. Edit the body, and see what status your own call would get.

## Idempotency

Issue and redeem can both carry a key that makes the call safe to repeat. The same key twice answers with the result of the FIRST time (same card, same entry) instead of issuing or deducting twice. The two verbs do not detect the same thing, though: on REDEEM we answer 409 idempotency_key_conflict if the key has already been used on a different card or on a payout, instead of hitting the wrong one. On ISSUE we do not compare the call. If you reuse a key with a different amount, you get 201 and the FIRST card's data back, with no error. Use one key per card.

The key can be in three places. We read idempotencyKey in the body first, and otherwise the Idempotency-Key header and then X-Request-Guid. The body wins when there is both.

| Where | Name |
| --- | --- |
| Body | idempotencyKey: the explicit choice, and the one that wins. |
| Header | Idempotency-Key: the name Stripe has made the standard. |
| Header | X-Request-Guid, so an integration moving from Lifepeaks works without being rewritten. |

> **Important:** A header set once for your whole HTTP client is not a key per call. Two redemptions on the SAME card with the same key return 200 and the first redemption's amount. Nothing is deducted the second time, and there is no error. The guest pays for the coffee with a card that was never charged. Set the key per call, or send it in the body.

## Issue a card

`POST /v1/api/giftcards`

Issues a card outside the payment flow, for example sold from your own system, invoiced to a company or given on the house.

`origin` is required here, whereas it is optional in the dashboard. A system issues with no one watching, and the choice follows the card for the rest of its life: `sold` means a customer has paid for it, and then the card carries the customer's right to have the remaining balance paid out in cash. `b2b` and `comp` do not.

> **Important:** All amounts are integers in øre. Send 500 meaning DKK 500oner, and you issue a card for five kroner.

### Body

- `amountOre` (`integer`, required): The card's value in øre, at least 1 øre and at most DKK 100,000.00.
- `origin` (`"sold" | "b2b" | "comp"`, required): `sold`: a customer has paid. `b2b`: invoiced to a company. `comp`: given on the house.
- `validityMonths` (`integer`, optional): Validity in months, at least 36 and at most 600. If left out, the card is valid for 36.
- `reference` (`string | null`, optional): Your own note, e.g. an invoice number. At most 200 characters.
- `noCashClaim` (`boolean`, optional): Mark the card «cannot be exchanged for cash», for example when it is for one particular thing. That does not by itself make an employee gift tax-free: SKAT, the Danish tax authority, treats digital gift cards as taxable. Only on `b2b` and `comp`.
- `idempotencyKey` (`string`, optional): The same key twice issues only one card. At most 120 characters. Can also be sent as a header (see Idempotency).

### Response fields

The card is under `giftCard`, in the same shape as for Look up a card, where the fields are described.

### Status codes

- `201`: The card has been issued. With an idempotencyKey that has been used before, the response is the card the key issued the first time.
- `400 invalid_issue_request`: The body doesn't match what issue requires.
- `404 merchant_not_found`: The key is valid, but the business behind it no longer exists. This is rare and cannot be fixed by a client. Contact us.
- `409 cash_claim_cannot_be_denied`: `noCashClaim` on a `sold` card. The customer has paid, and a flag cannot take away the right to have the remaining balance paid out.

### Request

```bash
curl -X POST https://api.kuvert.dk/v1/api/giftcards \
  -H "Authorization: Bearer $KUVERT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "amountOre": 50000,
    "origin": "b2b",
    "reference": "Faktura 2026-114",
    "idempotencyKey": "faktura-2026-114"
  }'
```

### Example response · 201

```json
{
  "giftCard": {
    "id": "gc_4f1c9a2e7b3d48e6a0c5d9f2b8e1a7c3",
    "code": "KUV-7K9F-2XQD",
    "orderId": null,
    "status": "active",
    "initialOre": 50000,
    "balanceOre": 50000,
    "expiresAt": "2029-09-29T08:00:00.000Z",
    "merchantSlug": "cafe-noir",
    "origin": "b2b",
    "reference": "Faktura 2026-114",
    "punchValueOre": null,
    "noCashClaim": false,
    "productName": null
  }
}
```

## List cards

`GET /v1/api/giftcards`

All of the business's cards, newest first, with the balance worked out from the ledger at that moment. The list is not paginated.

### Response fields

The cards are under `giftCards`, each in the same shape as for Look up a card.

### Status codes

- `200`: The list, possibly empty.

### Request

```bash
curl https://api.kuvert.dk/v1/api/giftcards \
  -H "Authorization: Bearer $KUVERT_API_KEY"
```

### Example response · 200

```json
{
  "giftCards": [
    {
      "id": "gc_4f1c9a2e7b3d48e6a0c5d9f2b8e1a7c3",
      "code": "KUV-7K9F-2XQD",
      "orderId": null,
      "status": "active",
      "initialOre": 50000,
      "balanceOre": 50000,
      "expiresAt": "2029-09-29T08:00:00.000Z",
      "merchantSlug": "cafe-noir",
      "origin": "b2b",
      "reference": "Faktura 2026-114",
      "punchValueOre": null,
      "noCashClaim": false,
      "productName": null
    },
    {
      "id": "gc_9b2e61d04c7f4a3e8d5b1c6a0f2e9d47",
      "code": "KUV-M4TQ-8WZR",
      "orderId": "ord_7d3f0b9c1e2a45f68b4c9d0e1f2a3b5c",
      "status": "active",
      "initialOre": 50000,
      "balanceOre": 43600,
      "expiresAt": "2029-06-12T14:31:07.000Z",
      "merchantSlug": "cafe-noir",
      "origin": "paid",
      "reference": null,
      "punchValueOre": null,
      "noCashClaim": false,
      "productName": "Gavekort"
    }
  ]
}
```

## Look up a card

`GET /v1/api/giftcards/{code}`

A card by its code, with the balance as it stands right now.

> **Note:** You can send the code in whatever form your scanner gives you: the full address from the QR code, the code without hyphens, lower case. Kuvert normalises it. A ticket code (TKT-…) responds 400 not_a_gift_card, because it belongs to check-in, not to a gift card. The same applies to redemption.

### Path parameters

- `code` (`string`, required): The card's code, e.g. `KUV-7K9F-2XQD`.

### Response fields

The card is under `giftCard`:

- `id` (`string`): The card's id (`gc_…`). The code is what you look it up by.
- `code` (`string`): The code printed on the card.
- `orderId` (`string | null`): The order the card came from. `null` for a card issued by hand or through the API.
- `status` (`"active" | "redeemed" | "expired" | "void" | "disputed"`): The card's state. Only `active` can be redeemed.
- `initialOre` (`integer`): The card's value in øre, before anything was redeemed.
- `balanceOre` (`integer`): The balance in øre, worked out from the entries and not read from a field.
- `expiresAt` (`string`): When the card expires, ISO 8601 in UTC.
- `merchantSlug` (`string`): The shop's address on shop.kuvert.dk.
- `origin` (`"paid" | "sold" | "b2b" | "corporate" | "comp"`): Where the value came from. `paid` was bought in the shop and `corporate` bought as a corporate gift; `sold`, `b2b` and `comp` were issued by hand or through the API.
- `reference` (`string | null`): Your own note from the issue. `null` on a bought card.
- `punchValueOre` (`integer | null`): The value of one punch if the card is a punch card, otherwise `null`. A punch card is redeemed in whole punches.
- `noCashClaim` (`boolean`): The card cannot be exchanged for cash.
- `productName` (`string | null`): The product's name as it read when the card was issued. `null` on a card issued by hand.

### Status codes

- `200`: The card.
- `400 not_a_gift_card`: The code in the path is a ticket code (TKT-…), not a gift card.
- `404 gift_card_not_found`: The code is not found on your business.

### Request

```bash
curl https://api.kuvert.dk/v1/api/giftcards/KUV-7K9F-2XQD \
  -H "Authorization: Bearer $KUVERT_API_KEY"
```

### Example response · 200

```json
{
  "giftCard": {
    "id": "gc_4f1c9a2e7b3d48e6a0c5d9f2b8e1a7c3",
    "code": "KUV-7K9F-2XQD",
    "orderId": null,
    "status": "active",
    "initialOre": 50000,
    "balanceOre": 50000,
    "expiresAt": "2029-09-29T08:00:00.000Z",
    "merchantSlug": "cafe-noir",
    "origin": "b2b",
    "reference": "Faktura 2026-114",
    "punchValueOre": null,
    "noCashClaim": false,
    "productName": null
  }
}
```

## Redeem

`POST /v1/api/giftcards/{code}/redeem`

Debits an amount from the card's balance. The code is in the path, the amount in the body.

Save the `id`. It is the posting, and it is the only thing that can reverse this specific redemption.

If it does not go through, the response is one of these. They are not all the same for the till: some are something the guest needs to know, and some are something the business needs to be called about.

> **Note:** Always send an idempotencyKey from a till. Timer the network out, and the terminal tries again, the key ensures the guest is only debited once.

> **Important:** Read the status code, not just 200. A till that treats anything other than 200 as «unknown error» cannot tell an expired card from a frozen one, or from one that just needs a few more hours. Those are three different things to tell the guest.

### Path parameters

- `code` (`string`, required): The card's code, e.g. `KUV-7K9F-2XQD`.

### Body

- `amountOre` (`integer`, required): The amount in øre, at least 1. A punch card only takes a whole number of punches.
- `location` (`string | null`, optional): Where it happened, e.g. «Vesterbro». At most 120 characters.
- `idempotencyKey` (`string`, optional): The same key twice deducts only once. At most 120 characters. Can also be sent as a header (see Idempotency).

### Response fields

The response is the ledger entry:

- `id` (`string`): The entry's id (`red_…`), which you reverse it with.
- `code` (`string`): The card's code.
- `amountOre` (`integer`): The amount drawn, in øre.
- `remainingBalanceOre` (`integer`): The balance after the redemption.
- `status` (`"active" | "redeemed" | "expired" | "void" | "disputed"`): `status` is the card's state after the redemption, one of `active`, `redeemed`, `expired`, `void`, `disputed`. If the balance is used up, it changes to `redeemed`.

### Status codes

- `200`: The amount has been deducted. With an idempotencyKey used before on the same card, the response is the one from the first time, and nothing is deducted again.
- `400 invalid_redeem_request`: The body does not match what redemption requires, typically an amount that is not a whole number of øre, or that is zero.
- `400 not_a_gift_card`: The code in the path is a ticket code (TKT-…), not a gift card.
- `404 gift_card_not_found`: The code is not found on your business.
- `409 card_inactive`: The card is not active: used up, cancelled, set to expired or frozen during a dispute. It does not clear on its own, so the business must be contacted.
- `409 redeem_too_soon`: The business has set a redemption delay, and it has not expired yet. The card is not wrong; it just cannot be used yet. Try again later.
- `409 idempotency_key_conflict`: The key has already been used on a different card or on a payout of the same card. See Idempotency.
- `410 gift_card_expired`: The card still shows as active, but the validity date has passed. Unlike card_inactive, it is time and not a decision that has closed the card.
- `422 insufficient_balance`: The amount is larger than the balance. We never deduct a partial amount on our own. Look up the card, deduct the balance and take the rest another way.
- `422 invalid_amount`: The redemption rule's own answer to an invalid amount. The schema normally catches it already as 400 invalid_redeem_request, so a client should be able to read both.
- `422 punch_amount_not_whole`: The card is a punch card, and the amount is not a whole number of punches. There is nothing wrong with the number, but it is the wrong kind of card for it. Look up the card, read punchValueOre, and send that value or a multiple of it. A punch card left with less than one punch is money neither the till nor the customer can reach.

### Request

```bash
curl -X POST https://api.kuvert.dk/v1/api/giftcards/KUV-7K9F-2XQD/redeem \
  -H "Authorization: Bearer $KUVERT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "amountOre": 6400,
    "location": "Vesterbro",
    "idempotencyKey": "pos-3-20260929-0142"
  }'
```

### Example response · 200

```json
{
  "id": "red_8f21c0b45d3e4a9b8c7d6e5f4a3b2c1d",
  "code": "KUV-7K9F-2XQD",
  "amountOre": 6400,
  "remainingBalanceOre": 43600,
  "status": "active"
}
```

## Reverse a redemption

`POST /v1/api/redemptions/{id}/reverse`

Puts the amount back on the card. This happens as a new entry with a negative amount that points to the one it reverses. Nothing is deleted, and both lines stay in the card's history. If the card was used up completely, it becomes active again.

> **Note:** The reversal triggers giftcard.redeemed like any other entry, just with a negative amount. A system that mirrors balances should therefore add the amount, not ignore the event.

### Path parameters

- `id` (`string`, required): The redemption's id (`red_…`), which you got back from Redeem.

### Response fields

The response is the entry that put the amount back, in the same shape as for Redeem, with a negative `amountOre`.

### Status codes

- `200`: The amount is put back.
- `404 redemption_not_found`: The posting is not found on your business.
- `409 already_reversed`: It has been reversed before. Once, never twice.
- `409 not_reversible`: The posting is itself a reversal or a cash payout.
- `409 card_inactive`: The card is cancelled or frozen under a dispute.
- `409 order_refunded`: The order behind the card has been refunded. The buyer already got the money back, so a reversal would give them the value twice.

### Request

```bash
curl -X POST https://api.kuvert.dk/v1/api/redemptions/red_8f21c0b45d3e4a9b8c7d6e5f4a3b2c1d/reverse \
  -H "Authorization: Bearer $KUVERT_API_KEY"
```

### Example response · 200

```json
{
  "id": "red_2c77af90e1d24b3c9a8f7e6d5c4b3a21",
  "code": "KUV-7K9F-2XQD",
  "amountOre": -6400,
  "remainingBalanceOre": 50000,
  "status": "active"
}
```

## Send the card again

`POST /v1/api/giftcards/{code}/resend`

Sends the card to the recipient again. That is what a till does when a customer says the email never arrived.

Without `recipient`, we send whatever the card's order has not yet had delivered, to the address it has. A card that has already been delivered is not sent again. With `recipient`, you correct the address on this particular card and send it there, even if it has been delivered before. Only active cards are sent.

### Path parameters

- `code` (`string`, required): The card's code, e.g. `KUV-7K9F-2XQD`.

### Body

- `recipient` (`string`, optional): A new email address for the card. The body can be left out entirely.

### Response fields

The response says how much was sent:

- `delivered` (`integer`): The number of cards sent. 0 means there was nothing to send.

### Status codes

- `200`: Sent, or nothing to send.
- `400 not_a_gift_card`: The code in the path is a ticket code (TKT-…), not a gift card.
- `404 gift_card_not_found`: The code is not found on your business.
- `404 order_not_found`: Resend found the card, but not the order it came from. A card issued by hand has no order and therefore cannot be resent that way.
- `409 card_has_no_delivery`: The card was never sent by us, because it was handed over the counter. There is no delivery to repeat.
- `422 invalid_recipient`: `recipient` in the body of resend is not an email address we can send to. Omit the field to send to the address the card already has.
- `502 delivery_failed`: We reached our provider, and it rejected the delivery. Nothing is wrong with the card, and nothing has been charged. Try again, or check the address.

### Request

```bash
curl -X POST https://api.kuvert.dk/v1/api/giftcards/KUV-M4TQ-8WZR/resend \
  -H "Authorization: Bearer $KUVERT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "recipient": "ny-adresse@dit-domaene.dk"
  }'
```

### Example response · 200

```json
{
  "delivered": 1
}
```

## Cancel a card

`POST /v1/api/giftcards/{code}/cancel`

Cancels the card immediately. The balance stays on the card, but it can no longer be used, and this cannot be undone. If the card is already cancelled, you get it back as it is, without an error.

### Path parameters

- `code` (`string`, required): The card's code, e.g. `KUV-7K9F-2XQD`.

### Response fields

The card is under `giftCard`, now with `status` `void`.

### Status codes

- `200`: The card has been cancelled, or already was.
- `400 not_a_gift_card`: The code in the path is a ticket code (TKT-…), not a gift card.
- `404 gift_card_not_found`: The code is not found on your business.

### Request

```bash
curl -X POST https://api.kuvert.dk/v1/api/giftcards/KUV-7K9F-2XQD/cancel \
  -H "Authorization: Bearer $KUVERT_API_KEY"
```

### Example response · 200

```json
{
  "giftCard": {
    "id": "gc_4f1c9a2e7b3d48e6a0c5d9f2b8e1a7c3",
    "code": "KUV-7K9F-2XQD",
    "orderId": null,
    "status": "void",
    "initialOre": 50000,
    "balanceOre": 50000,
    "expiresAt": "2029-09-29T08:00:00.000Z",
    "merchantSlug": "cafe-noir",
    "origin": "b2b",
    "reference": "Faktura 2026-114",
    "punchValueOre": null,
    "noCashClaim": false,
    "productName": null
  }
}
```

## Refund an order

`POST /v1/api/orders/{id}/refund`

Sends the whole payment for the order back to the card that paid, including the buyer's handling fee, and Kuvert's fee on the sale goes back to you. It is the only endpoint that moves money.

Once Stripe has confirmed the refund, the value is taken off the order's cards, its tickets are cancelled, and order.refunded is sent. That usually happens a few seconds after the response.

The call takes no idempotency key, because the order's own status is the safeguard. An order that has already been refunded answers 409 order_not_refundable.

### Path parameters

- `id` (`string`, required): The order's id (`ord_…`): `orderId` on the card or in order.paid.

### Response fields

The response is the refund as Stripe accepted it:

- `orderId` (`string`): The order.
- `refundId` (`string`): Stripe's id for the refund (`re_…`), which you find it under in Stripe.
- `amountOre` (`integer`): The amount sent back, in øre.

### Status codes

- `200`: Stripe has accepted the refund.
- `404 order_not_found`: The order does not exist on your business.
- `409 order_not_refundable`: The order cannot be refunded: it is not paid, already refunded, or frozen during a dispute.
- `409 merchant_not_payment_ready`: Your Stripe account is not connected.
- `503 stripe_not_configured`: Payments are temporarily unavailable on our side. Try again later.

### Request

```bash
curl -X POST https://api.kuvert.dk/v1/api/orders/ord_7d3f0b9c1e2a45f68b4c9d0e1f2a3b5c/refund \
  -H "Authorization: Bearer $KUVERT_API_KEY"
```

### Example response · 200

```json
{
  "orderId": "ord_7d3f0b9c1e2a45f68b4c9d0e1f2a3b5c",
  "refundId": "re_3PqX9aLkT2mN8vRb1c4D7eFg",
  "amountOre": 50500
}
```
