# Webhooks (https://dev.cosmospay.lat/docs/api/webhooks/)

> Register endpoints, manage them, and inspect delivery attempts for payment events.

Base URL: `https://api.cosmospay.lat/cosmos-api`

## List the consumer's webhook endpoints

`GET /v1/webhooks`

Newest first, as `{ data, total, take, skip }` with `take` up to 100 (default 100), without signing secrets. `destinationBlocked` marks an endpoint this service disabled because its host stopped resolving to a public address.

Requires the `webhooks:read` scope.

**Query parameters**

- `take` (number, optional): Page size: how many items to return (1 to 100).
- `skip` (number, optional): How many items to skip before this page (offset).

**Responses**

- `200` (`WebhookEndpointListEntity`): OK
  - `data` (WebhookEndpointEntity[], required): The endpoints on this page, newest first.
  - `total` (number, required): Number of endpoints, across every page.
  - `take` (number, required): Page size applied (1 to 100).
  - `skip` (number, required): Number of endpoints skipped before this page.
- `400`: ValidationFailed (see Shared responses)
- `401`: Unauthorized (see Shared responses)
- `403`: Forbidden (see Shared responses)
- `500`: InternalError (see Shared responses)

## Register a webhook endpoint (returns the signing secret, shown once)

`POST /v1/webhooks`

`url` must be `https`, carry no credentials and resolve to a public address (loopback, private, link-local and cloud metadata hosts are `400 validation_failed`). `secret` (`whsec_...`) is returned only here and on rotation: every delivery is signed with it in the signature header (`x-cosmos-signature` by default) as `t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">`. An empty or omitted `eventTypes` receives every event type.

Requires the `webhooks:write` scope.

**Request body** (`application/json`, required): `CreateWebhookEndpointDto`

- `url` (string, required): HTTPS URL that will receive POSTed event notifications.
- `description` (string, optional): Free-text label for the endpoint (at most 255 characters).
- `eventTypes` (string[], optional): Event types to subscribe to. Omit/empty to receive all events.

**Responses**

- `201` (`WebhookEndpointWithSecretEntity`): Created
  - `id` (string, required): Webhook endpoint id.
  - `url` (string, required): The https URL deliveries are POSTed to.
  - `description` (object, required): Free-text label for the endpoint; null when none was given.
  - `enabled` (boolean, required): Whether deliveries are sent to this endpoint.
  - `destinationBlocked` (boolean, required): True when the URL failed public-destination checks (SSRF policy). Deliveries are skipped until the URL is updated to a safe https endpoint.
  - `eventTypes` (string[], required): Subscribed events; empty means all.
  - `createdAt` (string (date-time), required): When the endpoint was registered (ISO 8601).
  - `updatedAt` (string (date-time), required): When the endpoint last changed (ISO 8601).
  - `secret` (string, required): HMAC signing secret, shown once. Store it securely.
- `400`: ValidationFailed (see Shared responses)
- `401`: Unauthorized (see Shared responses)
- `403`: Forbidden (see Shared responses)
- `500`: InternalError (see Shared responses)

## Get a webhook endpoint

`GET /v1/webhooks/{id}`

The endpoint without its signing secret, which only the create and rotate-secret routes return.

Requires the `webhooks:read` scope.

**Path parameters**

- `id` (string, required)

**Responses**

- `200` (`WebhookEndpointEntity`): OK
  - `id` (string, required): Webhook endpoint id.
  - `url` (string, required): The https URL deliveries are POSTed to.
  - `description` (object, required): Free-text label for the endpoint; null when none was given.
  - `enabled` (boolean, required): Whether deliveries are sent to this endpoint.
  - `destinationBlocked` (boolean, required): True when the URL failed public-destination checks (SSRF policy). Deliveries are skipped until the URL is updated to a safe https endpoint.
  - `eventTypes` (string[], required): Subscribed events; empty means all.
  - `createdAt` (string (date-time), required): When the endpoint was registered (ISO 8601).
  - `updatedAt` (string (date-time), required): When the endpoint last changed (ISO 8601).
- `400`: ValidationFailed (see Shared responses)
- `401`: Unauthorized (see Shared responses)
- `403`: Forbidden (see Shared responses)
- `404`: NotFound (see Shared responses)
- `500`: InternalError (see Shared responses)

## Update a webhook endpoint (url/description/enabled/eventTypes)

`PATCH /v1/webhooks/{id}`

Changes only the fields sent. A new `url` is checked as on create, `enabled: true` checks the stored one again, and either clears `destinationBlocked`. `eventTypes` replaces the filter (an empty list receives every event type).

Requires the `webhooks:write` scope.

**Path parameters**

- `id` (string, required)

**Request body** (`application/json`, required): `UpdateWebhookEndpointDto`

- `url` (string, optional): New https URL for deliveries, checked as on create.
- `description` (string, optional): Free-text label for the endpoint (at most 255 characters).
- `enabled` (boolean, optional): Pause/resume deliveries to this endpoint.
- `eventTypes` (string[], optional): Event types to subscribe to; replaces the current filter. An empty list receives every event type.

**Responses**

- `200` (`WebhookEndpointEntity`): OK
- `400`: ValidationFailed (see Shared responses)
- `401`: Unauthorized (see Shared responses)
- `403`: Forbidden (see Shared responses)
- `404`: NotFound (see Shared responses)
- `500`: InternalError (see Shared responses)

## Delete a webhook endpoint

`DELETE /v1/webhooks/{id}`

Deletes the endpoint together with its delivery history; nothing more is sent to it.

Requires the `webhooks:write` scope.

**Path parameters**

- `id` (string, required)

**Responses**

- `200` (`WebhookDeletedEntity`): OK
  - `id` (string, required): Id of the deleted endpoint.
  - `deleted` (boolean, required): Always true.
- `400`: ValidationFailed (see Shared responses)
- `401`: Unauthorized (see Shared responses)
- `403`: Forbidden (see Shared responses)
- `404`: NotFound (see Shared responses)
- `500`: InternalError (see Shared responses)

## Rotate the signing secret (returns the new secret)

`POST /v1/webhooks/{id}/rotate-secret`

Replaces `secret` at once and returns the new one, shown only here. Every delivery sent from now on is signed with the new secret only: the old one has no grace period.

Requires the `webhooks:write` scope.

**Path parameters**

- `id` (string, required)

**Responses**

- `201` (`WebhookEndpointWithSecretEntity`): Created
- `400`: ValidationFailed (see Shared responses)
- `401`: Unauthorized (see Shared responses)
- `403`: Forbidden (see Shared responses)
- `404`: NotFound (see Shared responses)
- `500`: InternalError (see Shared responses)

## Send a test event to verify the endpoint

`POST /v1/webhooks/{id}/ping`

POSTs one signed `ping` event (not stored as a delivery) to the endpoint, enabled or not, and answers whether it replied 2xx (`ok`, `responseStatus`, `error`). A URL that no longer resolves to a public address disables the endpoint and marks it `destinationBlocked`.

Requires the `webhooks:write` scope.

**Path parameters**

- `id` (string, required)

**Responses**

- `201` (`WebhookPingEntity`): Created
  - `ok` (boolean, required): True when the endpoint answered with a 2xx status.
  - `responseStatus` (object, required): HTTP status the endpoint answered; null when the request got no response.
  - `error` (object, required): Why the request failed when it got no response; null otherwise.
- `400`: ValidationFailed (see Shared responses)
- `401`: Unauthorized (see Shared responses)
- `403`: Forbidden (see Shared responses)
- `404`: NotFound (see Shared responses)
- `429`: RateLimited (see Shared responses)
- `500`: InternalError (see Shared responses)

## List delivery attempts for an endpoint (audit trail)

`GET /v1/webhooks/{id}/deliveries`

Newest first, as `{ data, total, take, skip }` with `take` up to 100 (default 20), narrowed by `status` when given. Each delivery reports its `eventType`, `eventId`, `attempts`, last `responseStatus` and `error`; the event body that was sent is never returned.

Requires the `webhooks:read` scope.

**Path parameters**

- `id` (string, required)

**Query parameters**

- `status` (string, optional): Only deliveries in this status. Omit for every status. One of: `PENDING`, `SUCCEEDED`, `FAILED`, `RETRYING`.
- `take` (number, optional): Page size: how many items to return (1 to 100).
- `skip` (number, optional): How many items to skip before this page (offset).

**Responses**

- `200` (`WebhookDeliveryListEntity`): OK
  - `data` (WebhookDeliveryEntity[], required): The deliveries on this page, newest first.
  - `total` (number, required): Number of deliveries matching the filter, across every page.
  - `take` (number, required): Page size applied (1 to 100).
  - `skip` (number, required): Number of deliveries skipped before this page.
- `400`: ValidationFailed (see Shared responses)
- `401`: Unauthorized (see Shared responses)
- `403`: Forbidden (see Shared responses)
- `404`: NotFound (see Shared responses)
- `500`: InternalError (see Shared responses)

## Manually re-send a past delivery

`POST /v1/webhooks/{id}/deliveries/{deliveryId}/redeliver`

Re-sends the stored body with the same event id, signed again with the endpoint's current secret, retrying with backoff inside this request until a 2xx or the attempts run out, and answers the updated delivery (without its body). A delivery past its payload retention window is `409 payload_expired`.

Requires the `webhooks:write` scope.

**Path parameters**

- `id` (string, required)
- `deliveryId` (string, required)

**Responses**

- `201` (`WebhookDeliveryEntity`): Created
  - `id` (string, required): Delivery id.
  - `endpointId` (string, required): Id of the endpoint the event was sent to.
  - `eventType` (string, required): Type of the event delivered. One of: `PAYMENT_INTENT_CREATED`, `PAYMENT_INTENT_UPDATED`, `PAYMENT_INTENT_SUCCEEDED`, `PAYMENT_INTENT_FAILED`, `PAYMENT_INTENT_CANCELLED`, `PAYMENT_INTENT_DELETED`, `RECEIVER_UPDATED`, `PAYIN_CREATED`, `PAYIN_UPDATED`, `PAYIN_COMPLETED`, `PAYOUT_CREATED`, `PAYOUT_UPDATED`, `PAYOUT_COMPLETED`, `SWAP_CREATED`, `SWAP_SUBMITTED`, `SWAP_SUCCEEDED`, `SWAP_FAILED`, `LIQUIDITY_CREATED`, `LIQUIDITY_SUBMITTED`, `LIQUIDITY_SUCCEEDED`, `LIQUIDITY_FAILED`, `SWAP_EXPIRED`, `LIQUIDITY_EXPIRED`, `CROSS_CHAIN_SWAP_CREATED`, `CROSS_CHAIN_SWAP_UPDATED`, `CROSS_CHAIN_SWAP_SUCCEEDED`, `CROSS_CHAIN_SWAP_REFUNDED`, `CROSS_CHAIN_SWAP_FAILED`, `CROSS_CHAIN_SWAP_EXPIRED`, `ZK_PAYMENT_SUCCEEDED`, `PROTECTED_PAYMENT_CREATED`, `PROTECTED_PAYMENT_FUNDED`, `PROTECTED_PAYMENT_SHIPPED`, `PROTECTED_PAYMENT_APPROVED`, `PROTECTED_PAYMENT_RELEASED`, `PROTECTED_PAYMENT_DISPUTED`, `PROTECTED_PAYMENT_RESOLVED`, `PROTECTED_PAYMENT_DEADLINE_PASSED`, `PROTECTED_PAYMENT_EXPIRED`, `PROTECTED_PAYMENT_CANCELLED`.
  - `eventId` (string, required): Stable event id sent to the receiver (`x-cosmos-event-id`), for idempotency.
  - `status` (string, required): Delivery outcome: `PENDING` while it is being sent, `SUCCEEDED` once the endpoint answered 2xx, `FAILED` when no attempt did. One of: `PENDING`, `SUCCEEDED`, `FAILED`, `RETRYING`.
  - `attempts` (number, required): Number of POST attempts made so far.
  - `responseStatus` (object, required): HTTP status of the last attempt; null when it got no response.
  - `error` (object, required): Why the last attempt failed (e.g. `Non-2xx response: 500`); null on success.
  - `lastAttemptAt` (object, required): When the last attempt was made (ISO 8601); null before the first.
  - `createdAt` (string (date-time), required): When the delivery was created (ISO 8601).
  - `updatedAt` (string (date-time), required): When the delivery last changed (ISO 8601).
- `400`: ValidationFailed (see Shared responses)
- `401`: Unauthorized (see Shared responses)
- `403`: Forbidden (see Shared responses)
- `404`: NotFound (see Shared responses)
- `409` (`ApiErrorBodyEntity`): `payload_expired`: the delivery is past its retention window. The event body was cleared, so there is nothing left to re-send; the delivery row itself stays as an audit record.
  - `statusCode` (number, required): Always the HTTP status of the response itself.
  - `code` (string, required): Stable machine-readable code. Branch on this, never on the message: messages are human-facing and may be reworded; codes are never renamed once published. Each response documents the subset it can return. One of: `insufficient_scope`, `no_authenticated_consumer`, `gateway_required`, `admin_console_only`, `not_found`, `validation_failed`, `payload_too_large`, `idempotency_conflict`, `operation_in_flight`, `invalid_state_transition`, `slippage_exceeded`, `insufficient_balance`, `no_path_found`, `trustline_missing`, `invalid_amount`, `invalid_memo`, `transaction_rejected`, `transaction_already_settled`, `recipient_not_registered`, `payment_transfer_mismatch`, `fee_transfer_mismatch`, `merchant_fee_transfer_mismatch`, `payer_mismatch`, `transaction_not_found`, `expired`, `network_unsupported`, `asset_unsupported`, `merchant_fee_unsupported`, `provider_error`, `provider_unavailable`, `quote_not_found`, `quote_expired`, `kyc_state_invalid`, `kyc_review_required`, `account_disabled`, `payload_expired`, `rate_limited`, `customer_wallet_taken`, `alias_taken`, `alias_name_invalid`, `alias_challenge_invalid`, `alias_signature_invalid`, `alias_recovery_invalid`, `alias_address_conflict`, `wallet_handshake_invalid`, `wallet_verifier_invalid`, `wallet_login_code_invalid`, `wallet_login_code_cooldown`, `wallet_session_invalid`, `wallet_signature_invalid`, `wallet_backup_invalid`, `wallet_backup_limit`, `wallet_account_mismatch`, `wallet_provider_unavailable`, `wallet_return_url_not_allowed`, `wallet_recovery_setup_refused`, `easy_recovery_not_found`, `easy_recovery_already_offered`, `easy_recovery_not_offered`, `easy_recovery_code_invalid`, `easy_recovery_burned`, `plugin_not_installed`, `plugin_consent_mismatch`, `plugin_rejected`, `plugin_quota_exceeded`, `plugin_failed`, `protected_payment_buyer_mismatch`, `protected_payment_signature_invalid`, `misconfigured`, `internal_error`.
  - `error` (string, required): The HTTP reason phrase for `statusCode`.
  - `message` (string | string[], required): Human-readable detail. An array when class-validator rejected the body.
  - `details` (object, optional): Machine-readable facts about this failure, present only on the codes whose example shows it: `easy_recovery_code_invalid` carries `{ "attemptsLeft": 4 }`. Absent everywhere else.
  - `path` (string, required): The path of the request that failed.
  - `timestamp` (string, required): When the envelope was produced, ISO 8601.
- `429`: RateLimited (see Shared responses)
- `500`: InternalError (see Shared responses)

## Shared responses

- **ValidationFailed** (`ApiErrorBodyEntity`): The body, query or path failed validation. `message` is an array of the individual failures. Routes that reject a *valid* request for a reason of their own (an amount, a memo, a state) document the code for it on their own 400.
- **Unauthorized** (`ApiErrorBodyEntity`): `no_authenticated_consumer`: The gateway forwarded no API key identity
- **Forbidden** (`ApiErrorBodyEntity`): `insufficient_scope`: the API key does not hold the scope this route requires. The shared public API key also gets this on every route that is not marked `x-cosmos-public-key`, with a message saying so; more scopes will not help, the key is the wrong one. `gateway_required`: the request did not arrive through APISIX.
- **InternalError** (`ApiErrorBodyEntity`): Unexpected server error. The detail is logged server-side and never returned: there is nothing in the body to branch on beyond the code.
- **NotFound** (`ApiErrorBodyEntity`): No such resource, or it exists and belongs to another consumer. The two are deliberately the same answer: "exists but not yours" is an ownership oracle.
- **RateLimited** (`ApiErrorBodyEntity`): The budget in `x-cosmos-rate-limit` is spent for this caller. Honour `Retry-After`. The `ratelimit-*` triple rides along on successful responses too, so a client can pace itself instead of being refused. If the counter store itself is unreachable the guard fails closed and answers `503 provider_unavailable`.
