Notifications
Tenant-level alerts surfaced to users of the system. The API produces two categories of notifications:
- Event-driven — created automatically when something happens (e.g. a document is authorized by SRI).
- Scheduled — created or updated by the API's own background job (e.g. certificate expiry). No consumer action required.
GET /v1/notifications
POST /v1/notifications/:id/read
GET /v1/notifications/preferences
PATCH /v1/notifications/preferencesSee Webhooks for registering callback URLs that receive notifications in near-real time. Polling this endpoint with ?sinceId= is the fallback for consumers that cannot expose a public HTTPS callback URL.
Authentication
Authorization: Bearer <api-key> — any active key for the tenant. No X-Issuer-Id required by default; supply it to scope results to a specific issuer (see Issuer filter).
Notification object
All list and single-notification responses use the same shape:
{
"id": "00000000-0000-0000-0000-000000000042",
"type": "DOCUMENT_AUTHORIZED",
"severity": "INFO",
"title": "Invoice authorized",
"message": "Invoice 001-001-000000012 for ACME Corp was authorized by SRI.",
"metadata": { "accessKey": "...", "sequential": "001-001-000000012", "total": "118.00" },
"issuerId": "00000000-0000-0000-0000-000000000003",
"readAt": null,
"expiresAt": null,
"createdAt": "2026-05-28T14:30:00.000Z"
}| Field | Type | Description |
|---|---|---|
id | string (UUID) | Stable identifier. Use it to deduplicate across polls and track per-user read state. |
type | string | Machine-readable type code — see Notification types. |
severity | string | INFO · WARNING · ERROR |
title | string | Short human-readable headline. |
message | string | Full human-readable description. |
metadata | object|null | Type-specific structured data (see Notification types). |
issuerId | string (UUID)|null | The issuer this notification concerns, or null for tenant-level alerts. |
readAt | string|null | ISO timestamp when the notification was marked read, or null if still unread. |
expiresAt | string|null | ISO timestamp after which the notification should be hidden, or null if it never expires. |
createdAt | string | ISO timestamp of creation. |
Notification types
DOCUMENT_AUTHORIZED
Created automatically (fire-and-forget) inside GET /:accessKey/authorize when SRI confirms authorization. Multiple authorizations within a 60-second window are aggregated into a single row to avoid flooding the list during batch processing. The same notification id may have an updated count on successive polls within that window — the frontend should upsert by id rather than append.
A webhook payload is fired for each update to the aggregated row (including count increments).
Severity: INFO
Metadata:
{
"documents": [
{
"accessKey": "...",
"sequential": "001-001-000000012",
"buyerName": "ACME Corp",
"buyerId": "0901234567001",
"total": "118.00",
"issueDate": "2026-05-28",
"authorizationNumber": "2605202615..."
}
],
"count": 5
}documents is capped at 50 entries when a batch is large; count always reflects the true total.
CERT_EXPIRING
Created or updated by the API scheduler job when an issuer's certificate is within 30 days of its notAfter date. At most one unread row per issuer — the same row is updated in place on successive job runs (days remaining refreshes, severity may escalate). Auto-dismissed when the certificate is renewed and has > 30 days remaining.
Severity: WARNING (> 7 days) · ERROR (≤ 7 days)
Metadata:
{
"issuerId": "00000000-0000-0000-0000-000000000003",
"certExpiry": "2026-06-15T00:00:00.000Z",
"daysRemaining": 18,
"branchCode": "001",
"issuePointCode": "001"
}CERT_EXPIRED
Same conditions as CERT_EXPIRING but for a certificate whose notAfter date has already passed.
Severity: ERROR
Metadata: same shape as CERT_EXPIRING, with daysRemaining: 0.
PAYMENT_VERIFIED
Created automatically (fire-and-forget) when your provider verifies a payment proof you uploaded — covers an initial subscription, a tier-change upgrade, and a renewal uniformly; only the wording differs. A matching email is sent at the same time.
Severity: INFO
Metadata:
{
"paymentId": "00000000-0000-0000-0000-000000000018",
"subscriptionId": "00000000-0000-0000-0000-000000000012",
"tier": "STARTER",
"billingInterval": "MONTHLY",
"purpose": "INITIAL",
"amount": "20.00",
"rejectionReasonCode": null
}purpose is INITIAL, TIER_CHANGE, or RENEWAL. For a TIER_CHANGE payment, tier/billingInterval are the target plan being purchased, not the subscription's current one — e.g. on a payment for a monthly-STARTER-to-yearly-GROWTH change, this shows "tier": "GROWTH", "billingInterval": "YEARLY". amount is the full IVA-inclusive total (what's actually transferred via SPI), not the pre-IVA base.
PAYMENT_REJECTED
Same trigger as PAYMENT_VERIFIED, for a rejected decision instead.
Severity: WARNING
Metadata: same shape as PAYMENT_VERIFIED, with rejectionReasonCode populated (one of AMOUNT_MISMATCH, TRANSFER_NOT_FOUND, WRONG_ACCOUNT, ILLEGIBLE_PROOF, DUPLICATE_SUBMISSION, OTHER) — re-submit proof for the same paymentId via Submit Payment Proof.
SUBSCRIPTION_RENEWAL_DUE
Created automatically by the provider's scheduled job about 7 days before your subscription's current_period_end. A new RENEWAL payment is already open by the time this fires — submit proof via Submit Payment Proof using the paymentId in the metadata. A matching email includes the bank transfer instructions.
Severity: WARNING
Metadata:
{
"subscriptionId": "00000000-0000-0000-0000-000000000012",
"paymentId": "00000000-0000-0000-0000-000000000025",
"tier": "STARTER",
"amount": "17.39",
"ivaAmount": "2.61",
"totalAmount": "20.00",
"currentPeriodEnd": "2026-07-15T00:00:00.000Z"
}SUBSCRIPTION_PAST_DUE_WARNING
Created automatically by the same scheduled job, later in the grace period than SUBSCRIPTION_RENEWAL_DUE (by default, 5 days after current_period_end, out of a 7-day total grace period) — a more urgent notice distinct from the renewal reminder, before the account actually becomes PAST_DUE. Submit proof for the already-open RENEWAL payment via Submit Payment Proof, or start a new subscription via Create Subscription — both routes stay reachable even after the account becomes PAST_DUE (see below).
Severity: WARNING
Metadata:
{
"subscriptionId": "00000000-0000-0000-0000-000000000012",
"tier": "STARTER",
"currentPeriodEnd": "2026-07-15T00:00:00.000Z",
"suspendsAt": "2026-07-22T00:00:00.000Z"
}SUBSCRIPTION_EXPIRED
Created automatically by the same scheduled job when a subscription runs about 7 days past current_period_end with no renewal ever verified. By the time this fires, the tenant has already been moved to the FREE tier and their account (GET /v1/tenants/me's status) becomes PAST_DUE — distinct from SUSPENDED: an account that can recover itself by starting a new subscription via Create Subscription and paying for it, no need to contact support. A matching email explains what happened.
Severity: ERROR
Metadata:
{
"subscriptionId": "00000000-0000-0000-0000-000000000012",
"previousTier": "STARTER"
}PRICE_CHANGE_ANNOUNCED
Created automatically when the operator publishes a price change for a paid tier, for every active tenant still inside the 30-day notice window. This type is mandatory — it cannot be unsubscribed on any channel (see Get preferences) because it is the actual 30-day legal price-change notice, not an optional operational alert. Any renewal due before effectiveAt is still billed at the current price.
Severity: INFO
Metadata:
{
"tierPriceId": "00000000-0000-0000-0000-000000000030",
"tier": "STARTER",
"billingInterval": "MONTHLY",
"previousPriceUsd": 20,
"newPriceUsd": 25,
"effectiveAt": "2026-08-25T00:00:00.000Z"
}Reserved types
The following types are defined in the schema and accepted by the preferences endpoint, but not yet produced by the API. They are reserved for future implementation:
| Type | Description |
|---|---|
SRI_SUBMISSION_FAILED | SRI permanently rejected a document submission |
EMAIL_DELIVERY_FAILED | Mailgun reported a permanent delivery failure |
QUOTA_WARNING | Tenant is approaching their document quota |
List notifications
GET /v1/notificationsReturns active (unexpired) notifications for the tenant, newest first. Both read and unread are included. Use readAt to decide what to show as new.
Query parameters
| Parameter | Type | Description |
|---|---|---|
sinceId | string (UUID) | Optional. When provided, returns only notifications with id > sinceId. Use for efficient catch-up polling: store the highest id seen on each poll and pass it on the next request. |
Issuer filter
Supply X-Issuer-Id: <id> to restrict results to a specific issuer. When the header is present, the response includes:
- Notifications whose
issuerIdmatches the supplied value. - Tenant-level notifications (
issuerId: null), such as future quota warnings.
Omit the header to receive all notifications across every issuer (useful for admin or overview pages).
Response
200 OK
{
"notifications": [ ... ],
"unreadCount": 3
}Errors
| Status | Code | When |
|---|---|---|
400 | ISSUER_ID_INVALID | X-Issuer-Id header is present but not a valid positive integer |
400 | ISSUER_ID_INVALID | sinceId is present but not a valid positive integer |
401 | UNAUTHORIZED | Missing or invalid API key |
Mark as read
POST /v1/notifications/:id/readMarks a single notification as read (readAt is set to now). The notification is excluded from unreadCount on all subsequent polls.
When to call: the frontend manages per-user read state in its own database. It calls this endpoint only when every user with access to the notification has marked it read on their side. After this call the notification is considered globally read and will no longer appear in unreadCount.
Path parameters
| Parameter | Description |
|---|---|
id | Notification UUID |
Response
200 OK
{
"notification": { ... }
}Errors
| Status | Code | When |
|---|---|---|
400 | VALIDATION_FAILED | id is not a positive integer |
401 | UNAUTHORIZED | Missing or invalid API key |
404 | NOT_FOUND | Notification does not exist, belongs to a different tenant, or is already read |
Get preferences
GET /v1/notifications/preferencesReturns the notification preference for every subscribable (type, channel) pair. Channels are IN_APP (controls whether the notification appears in GET /v1/notifications) and EMAIL (controls whether the matching email is sent, for types that have one). Pairs the tenant has never explicitly configured default to enabled: true (opt-out model).
Mandatory types (currently only PRICE_CHANGE_ANNOUNCED, the 30-day legal notice — see its section above) never appear in this list — they cannot be unsubscribed on any channel. A type with no matching email (DOCUMENT_AUTHORIZED, CERT_EXPIRING, CERT_EXPIRED) only appears with the IN_APP channel.
Response
200 OK
{
"preferences": [
{ "type": "DOCUMENT_AUTHORIZED", "channel": "IN_APP", "enabled": true },
{ "type": "CERT_EXPIRING", "channel": "IN_APP", "enabled": true },
{ "type": "CERT_EXPIRED", "channel": "IN_APP", "enabled": true },
{ "type": "PAYMENT_VERIFIED", "channel": "IN_APP", "enabled": true },
{ "type": "PAYMENT_VERIFIED", "channel": "EMAIL", "enabled": true },
{ "type": "PAYMENT_REJECTED", "channel": "IN_APP", "enabled": true },
{ "type": "PAYMENT_REJECTED", "channel": "EMAIL", "enabled": true },
{ "type": "SUBSCRIPTION_RENEWAL_DUE", "channel": "IN_APP", "enabled": true },
{ "type": "SUBSCRIPTION_RENEWAL_DUE", "channel": "EMAIL", "enabled": true },
{ "type": "SUBSCRIPTION_PAST_DUE_WARNING", "channel": "IN_APP", "enabled": true },
{ "type": "SUBSCRIPTION_PAST_DUE_WARNING", "channel": "EMAIL", "enabled": true },
{ "type": "SUBSCRIPTION_EXPIRED", "channel": "IN_APP", "enabled": true },
{ "type": "SUBSCRIPTION_EXPIRED", "channel": "EMAIL", "enabled": true }
]
}Update preferences
PATCH /v1/notifications/preferencesBulk-upsert one or more (type, channel) preferences. Send only the pairs you want to change; unmentioned pairs are unchanged.
Request body
An array of preference objects:
[
{ "type": "PAYMENT_VERIFIED", "channel": "EMAIL", "enabled": false }
]| Field | Type | Required | Description |
|---|---|---|---|
type | string | Yes | One of the subscribable (non-mandatory) notification types |
channel | string | Yes | IN_APP or EMAIL — must be a channel the type supports |
enabled | boolean | Yes | true to enable, false to suppress |
When enabled is false for (type, IN_APP), the notification stops appearing in GET /v1/notifications for that type — the row is still created internally (e.g. for webhook history), it's just filtered from the list. When enabled is false for (type, EMAIL), the matching email is not sent for that type, but the in-app notification is unaffected.
Response
200 OK — same shape as GET /v1/notifications/preferences, reflecting the full updated state.
Errors
| Status | Code | When |
|---|---|---|
400 | VALIDATION_FAILED | Body is not an array, or an entry has an invalid type/channel, a channel that type doesn't support, a mandatory type (cannot be unsubscribed), or a non-boolean enabled |
401 | UNAUTHORIZED | Missing or invalid API key |
Recommended integration pattern
┌─────────────────────────────────────────────────────────────────────┐
│ Consumer backend (e.g. Next.js) │
│ │
│ Primary (near-real-time): │
│ Register a webhook endpoint → receive events via POST callback │
│ Verify X-Comprobify-Signature on each incoming request │
│ │
│ Fallback / catch-up: │
│ Poll GET /v1/notifications?sinceId=<lastSeenId> every 60–300s │
│ Store highest id seen → pass as sinceId on next poll │
│ │
│ When user opens notification panel: │
│ Mark read in frontend DB per user │
│ When all users have read → POST /v1/notifications/:id/read │
└─────────────────────────────────────────────────────────────────────┘Choosing a poll interval
The DOCUMENT_AUTHORIZED notification type aggregates multiple authorizations that occur within a 60-second window into a single row. Polling more frequently than 60 seconds would catch the row mid-aggregation — it will update again within the same window anyway, so there is no benefit to polling faster than that.
| Scenario | Recommended interval |
|---|---|
| Webhooks configured (polling is fallback only) | 300 s (5 min) — any missed event is caught up on the next cycle |
| No webhooks, polling is the only delivery mechanism | 60 s — matches the aggregation window; going lower gives no benefit |
Do not poll below 60 seconds — it will not surface new data sooner and adds unnecessary load.