Event cancellation
Cancel an event through the Public API and optionally refund its zahls.ch payments
Cancelling an event through the API does exactly what the dashboard does: the event is set to CANCELLED, its pending and completed orders are cancelled, inventory is released and issued tickets are voided. Refunding the money is a separate, explicit choice.
Refunds are opt-in
refund defaults to false. A request without a body, or with "refund": false, cancels the event and moves no money. Only "refund": true queues refunds.
Authentication and scopes
All three operations use the same organization API key as the rest of the API — see Authentication.
| Operation | Method and path | Scope |
|---|---|---|
| Cancel an event | POST /events/{eventId}/cancel | events:cancel |
| Read refund status | GET /events/{eventId}/refunds | events:read |
| Retry failed refunds | POST /events/{eventId}/refunds/retry | events:cancel |
A key may only reach its own organization's events. An event belonging to another organization answers 404, never 403, so key holders cannot probe for event ids.
Cancel an event
Idempotency-Key is required. See Retries and idempotency below.
curl -X POST https://api.cowtic.com/api/v1/events/evt_2f8a/cancel \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
-H "Content-Type: application/json" \
-d '{ "refund": true }'{
"eventId": "evt_2f8a",
"status": "CANCELLED",
"cancelledOrderIds": ["ord_91c", "ord_44b", "ord_07e"],
"failedOrderIds": [],
"refunds": {
"queued": 2,
"queuedOrderIds": ["ord_91c", "ord_44b"],
"skipped": {
"refundable": 0,
"cancelled_earlier": 1,
"unpaid": 3,
"free": 1,
"manual_payment": 0,
"refunded": 0,
"refund_pending": 0,
"refund_failed": 0,
"refund_unknown": 0,
"refund_unsupported": 0
}
},
"refundStatusUrl": "/api/v1/events/evt_2f8a/refunds"
}| Field | Meaning |
|---|---|
cancelledOrderIds | Orders cancelled by this request. Empty on a repeat — they were already cancelled. |
failedOrderIds | Orders that could not be cancelled. They keep their tickets and are never refunded, so surface them to an operator. |
refunds | null when refund was false. Otherwise what this request handed to the refund queue. |
refunds.queuedOrderIds | Orders whose refund is now queued. |
refunds.skipped | Orders left alone, counted by eligibility. |
refundStatusUrl | Path on this API host to poll for the per-order outcome. |
Without a refund the call is shorter:
curl -X POST https://api.cowtic.com/api/v1/events/evt_2f8a/cancel \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Idempotency-Key: 4f1d0f8c-7a1e-4d51-9a1b-0d1f0b2d9a77"{
"eventId": "evt_2f8a",
"status": "CANCELLED",
"cancelledOrderIds": ["ord_91c", "ord_44b", "ord_07e"],
"failedOrderIds": [],
"refunds": null,
"refundStatusUrl": "/api/v1/events/evt_2f8a/refunds"
}Refunds are asynchronous
POST /events/{eventId}/cancel returns as soon as the orders are cancelled and the refunds are queued. Each refund is then sent to zahls.ch by a background worker. A 200 means queued, not refunded — read refundStatusUrl for the outcome.
Which orders get refunded
Only orders that were actually paid through zahls.ch and have not been refunded yet. Every other order is reported under refunds.skipped with the reason:
| Eligibility | Meaning |
|---|---|
refundable | Completed order with a captured zahls.ch payment. Queued for refund. |
cancelled_earlier | Cancelled before the event was, payment still captured. Never refunded automatically — it may already have been refunded by hand. Refund it from the order in the dashboard. |
unpaid | Checkout never completed. Nothing to refund. |
free | Free or fully discounted order. Nothing to refund. |
manual_payment | Marked as paid outside zahls.ch. Refund it through the channel that took the money. |
refunded | Already refunded. |
refund_pending | A refund is already queued or in flight. |
refund_failed | zahls.ch rejected the refund. Retryable. |
refund_unknown | zahls.ch never answered, so the refund may or may not have happened. Never retried automatically — check the transaction on zahls.ch first. |
refund_unsupported | The payment method cannot be refunded through zahls.ch. |
Retries and idempotency
Idempotency-Key is required on POST /events/{eventId}/cancel and POST /events/{eventId}/refunds/retry; a request without it returns 400.
- Repeating a request with the same key and the same body replays the stored response and runs nothing again. The replay carries
Idempotency-Replayed: true. - Reusing a key with a different body returns
409. - Use a new key for a genuinely new attempt — for example after fixing your zahls.ch connection.
The operation is safe to repeat even with a fresh key: an event already CANCELLED is not cancelled twice, an order already cancelled is left alone, and a refund record already created is never duplicated, so no order is refunded twice. See Idempotency for the general rules.
Read the refund status
curl https://api.cowtic.com/api/v1/events/evt_2f8a/refunds \
-H "X-Api-Key: YOUR_API_KEY"{
"eventStatus": "CANCELLED",
"total": 2,
"counts": { "PENDING": 0, "SUCCEEDED": 1, "FAILED": 1, "UNSUPPORTED": 0 },
"stalePending": 0,
"unknownOutcome": 0,
"refundedAmount": 50,
"currency": "CHF",
"refunds": [
{
"id": "prf_7d1",
"orderId": "ord_91c",
"orderStatus": "CANCELLED",
"customer": { "name": "Ada Guest", "email": "guest@example.com" },
"status": "SUCCEEDED",
"currency": "CHF",
"paidAmount": 50,
"requestedAmount": 50,
"refundedAmount": 50,
"failureReason": null,
"attempts": 1,
"lastAttemptAt": "2026-09-14T09:12:04.881Z",
"updatedAt": "2026-09-14T09:12:05.102Z",
"stale": false,
"outcomeUnknown": false,
"providerTransactionId": "5012345",
"providerRefundId": "9001"
},
{
"id": "prf_7d2",
"orderId": "ord_44b",
"orderStatus": "CANCELLED",
"customer": { "name": "Blaise Guest", "email": "blaise@example.com" },
"status": "FAILED",
"currency": "CHF",
"paidAmount": 12.5,
"requestedAmount": 12.5,
"refundedAmount": 0,
"failureReason": "Refund limit reached for this account",
"attempts": 1,
"lastAttemptAt": "2026-09-14T09:12:04.902Z",
"updatedAt": "2026-09-14T09:12:04.960Z",
"stale": false,
"outcomeUnknown": false,
"providerTransactionId": "5012346",
"providerRefundId": null
}
]
}| Field | Meaning |
|---|---|
counts | Refund records by status. PENDING means queued or in flight. |
stalePending | PENDING records no worker has touched for a while. A retry picks them up. |
unknownOutcome | Refunds zahls.ch never confirmed. Check the transaction on zahls.ch before retrying. |
refundedAmount / paidAmount | Amounts in the order's currency, not minor units. |
failureReason | The message zahls.ch returned. Provider text only — no credentials are ever included. |
providerTransactionId / providerRefundId | zahls.ch references for reconciliation. |
Poll this endpoint until counts.PENDING reaches 0. A few seconds between polls is plenty; mind the rate limits.
Retry failed refunds
Queues the event's FAILED, UNSUPPORTED and abandoned refunds again. The event must already be cancelled, otherwise the call returns 400 with detail EVENT_NOT_CANCELLED.
curl -X POST https://api.cowtic.com/api/v1/events/evt_2f8a/refunds/retry \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Idempotency-Key: 9a3cbe44-2f9d-4f9e-8b4f-3c2d1a7b5e60"{
"queued": 1,
"queuedOrderIds": ["ord_44b"],
"skipped": {
"refundable": 0,
"cancelled_earlier": 0,
"unpaid": 0,
"free": 0,
"manual_payment": 0,
"refunded": 1,
"refund_pending": 0,
"refund_failed": 0,
"refund_unknown": 0,
"refund_unsupported": 0
},
"refundStatusUrl": "/api/v1/events/evt_2f8a/refunds"
}Refunds counted under refund_unknown are deliberately left out: zahls.ch may already have executed them, so a person has to look before they are sent again.
Errors
| Status | type suffix | detail | What to do |
|---|---|---|---|
400 | validation-error | refund is not a boolean | Send true or false, or omit the body |
400 | bad-request | Idempotency-Key header is required for this endpoint | Add the header |
400 | bad-request | EVENT_NOT_CANCELLED | Cancel the event before retrying its refunds |
401 | unauthorized | Missing or invalid API key | Check the key |
403 | forbidden | API key lacks scope events:cancel | Grant the scope, or rotate the key with it |
404 | not-found | EVENT_NOT_FOUND | The event does not exist in this organization |
409 | idempotency-conflict | Key reused with a different body | Use a new key |
503 | service-unavailable | EVENT_PAYMENT_CREDENTIALS_REQUIRED | Connect zahls.ch in the dashboard, or cancel without refund |
Nothing is cancelled when a request fails validation, authorization or the zahls.ch check. Per-order failures are not errors: they come back in failedOrderIds and in the refund status. See Errors for the shared problem-details shape.
With the SDK
import { Cowtic } from "@cowtic/sdk";
const cowtic = new Cowtic({ apiKey: process.env.COWTIC_API_KEY! });
const { data: cancellation } = await cowtic.events.cancel("evt_2f8a", { refund: true });
console.log(cancellation.refunds?.queuedOrderIds);
const { data: status } = await cowtic.events.refunds("evt_2f8a");
if (status.counts.FAILED > 0) {
await cowtic.events.retryRefunds("evt_2f8a");
}The SDK generates an Idempotency-Key per call; pass your own with { idempotencyKey } to make a retry replay the first answer.