# Safe Creative Platform > Safe Creative Platform, event registration by API from Safe Creative: each event numbered, chained and covered by a qualified timestamp, with certifications anyone can verify. It certifies declarations (dated, intact receipt), never the truth of the declared fact. The API is at https://platform.safecreative.org/v1: `POST /events` registers an event and `POST /certifications` requests the certification of a range. The key decides the mode: `sc_live_…` or `sc_test_…` (test mode: same endpoints, separate chain, simulated timestamps, no charge). Channels and keys are created in the console. Every page is available in English (`?lang=en`) and Spanish (`?lang=es`), and every documentation page in Markdown: add `.md` to its URL. # Safe Creative Platform — API Guide ## 1. What this service is Safe Creative Platform is the Safe Creative service built for **systems**: your processes declare events through an API call at the moment they happen, and Safe Creative — an independent trusted third party — computes the cryptographic digest of each declaration, assigns it a sequence number, **chains** it to the previous event and covers it with the **qualified timestamp** of its segment, with legal presumption in the EU. When you need the proof, you request a **certification**: a report signed by Safe Creative with the evidence archive for the period, verifiable by anyone on a public page. **What exactly is certified** — worth being clear about from the start: Safe Creative attests that your declaration **was received, exactly when, and that nobody has altered it since**. It does not certify that the declared fact is true — the same logic as a notarized statement of record. What you gain is what your own systems can never give you: proof that is **contemporaneous with the fact, prior to any dispute, and held by a third party you don't control**. ## 2. Concepts | Concept | What it is | |---|---| | **Channel** | The conduit for one type of process. Created in the console; its configuration is **sealed with a timestamp** at creation (a commitment made before the facts). Changes create **versions**; none is ever rewritten. | | **Event** | One declaration sent to the channel. Immutable: it gets a sequence number, digest, timestamp coverage and chaining. | | **Modality** | Per channel: **content** (you send the data — a JSON token from your process, or a file — and the certification is self-contained) or **hash-only** (you send the digest; the content never leaves your perimeter). | | **Declarant** | The person or entity on whose behalf events are declared. Designated when configuring the channel; part of the sealed configuration. In reports it appears as a declaration of the channel owner. | | **Certification** | A PDF report signed by Safe Creative + evidence ZIP, covering a range of dates or sequence numbers. Issued on demand, asynchronously. | | **API key** | A channel credential. **Live** (`sc_live_…`) or **test** (`sc_test_…`): same contract, but test mode consumes no balance and produces no probative value. | ## 3. Up and running in ten minutes 1. **Account**: sign in to the platform.safecreative.org console with your Safe Creative account — any type works, including the free one. No account? Signing up is free and self-service. 2. **Channel**: create your first channel in the console (name, modality, declarant). You'll get configuration v1, sealed, plus your first key. Start with a **test key**. 3. **First event**: ``` curl -X POST https://platform.safecreative.org/v1/events \ -H "Authorization: Bearer sc_test_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "client_ref": "run-2026-08-27-0001", "payload": { "action": "review.completed", "case": "EXP-4411" }, "occurred_at": "2026-08-27T10:14:03Z" }' ``` Response (the acknowledgment is your receipt — store it): ``` { "event_id": "evt_9f3k27", "sequence": 1, "received_at": "2026-08-27T10:14:04.112Z", "hash": "b5bb9d8014a0f9b1d61e21e796d78dcc…", "chain_hash": "7d865e959b2466918c9863afca942d0f…", "status": "test", "config_version": 1, "mode": "test" } ``` 4. **Certify and verify**: request a certification for the range, download the test report (it will carry the "TEST" watermark) and check its code on the public verification page. 5. **Go live**: swap the test key for a live one. The contract is identical. ## 4. Registering events ### 4.1 The call [`POST /events`](/docs/reference.md#registerEvent) with your channel key. Depending on the channel's modality: - **Content (JSON)**: `client_ref` + `payload` (up to 256 KB). - **Content (file)**: multipart with `client_ref` + `file` (up to 10 MB). - **Hash-only**: `client_ref` + `hash` + `hash_alg` (`sha-256` or `sha-512`). You compute the digest of the data you hold; the data itself is never sent. `occurred_at` is optional and is **part of your declaration**: it states when you claim the fact happened. What Safe Creative attests with the timestamp is `received_at` — when it was received. Reports always keep the two apart. ### 4.2 Idempotency: retry without fear `client_ref` is your unique event id (per channel). If you retry the same call — timeout, network drop — the event is **not duplicated**: you get `200` with the original event. If you reuse a `client_ref` with different content, you get `409`: nothing is ever overwritten. Rule of thumb: generate the `client_ref` **before** calling (your transaction id, a persisted UUID) and retry with exponential backoff on `5xx` or timeouts. That makes your integration outage-proof. And if your process cannot afford to drop declarations during a longer outage, add a **local outbound queue** (a persistent buffer you resend from): your events will come in once the service is back, each with its actual reception time. ### 4.3 Sequence and ordering The sequence number is assigned by the service **at reception**: strictly increasing, gapless, per channel. The probative order is the order of reception, not `occurred_at`. If the order of your events matters, send them in order or serialize your calls. ## 5. Querying what's registered - [`GET /events`](/docs/reference.md#listEvents) — paginated, with filters by dates, sequences and status. - [`GET /events/{event_id}`](/docs/reference.md#getEvent) — one event with its full proof elements: digest, timestamp (or sealed-segment reference), sequence, chained digest, and the configuration version in force. Its `modality` field says whether it is a content event (`content`) or hash-only (`hash_only`). - [`GET /events/{event_id}/content`](/docs/reference.md#getEventContent) — in content modality, the content held by Safe Creative: the JSON in its canonical form (the one hashed) or the file with its original name. For a hash-only event, `404`. - [`GET /channel`](/docs/reference.md#getChannel) — the channel configuration and all its sealed versions. - [`GET /account/balance`](/docs/reference.md#getAccountBalance) — balance and operational status, so your systems can self-monitor. ## 6. Certifications [`POST /certifications`](/docs/reference.md#requestCertification) with the range (dates or sequence numbers). Issuance is **asynchronous**: you get `202` with an id, and poll [`GET /certifications/{id}`](/docs/reference.md#getCertification) — or get notified via the `certification.completed` webhook. When completed you'll have: - the **signed PDF report**, in **two versions, Spanish and English**, so it holds up before recipients in any jurisdiction without translation: a synthesis and attestation of the range's sequence integrity, with the configuration versions in force and the declarant designation. Both versions are signed separately, carry the same verification code and the same evidence, are equally authentic and each is verified on its own. `reports` lists both (language, URL and SHA-256); `report_url` is the one in the language of your `Accept-Language`; - the **evidence ZIP**: data or digests, timestamps, sequences and chained digests; - the **verification code**: anyone can check the report on the public verification page, without being a customer. Registering and certifying are separate moments: evidence accumulates quietly and you may never need it — like insurance. Certify when you do. ## 7. Webhooks Configure endpoints in the console and pick what to receive: `certification.completed` / `.failed`, `balance.low`, `balance.exhausted`, `events.unpaid_limit_reached`, `consumption.limit_reached`, `recharge.auto_completed` / `.auto_failed`. From the console you can also request a test delivery (`webhook.test`) to any endpoint. Every delivery shares the same JSON envelope: `id` (the same on retries), `type`, `created_at`, `mode` (`live` or `test`: balance and limit notices are always `live`; certification notices carry the certification's mode) and `data`, whose model depends on `type`: | `type` | Sent when | `data` fields | |---|---|---| | `certification.completed` | The certification has been issued | `certification_id`, `channel_id`, `status`, `event_count`, `verification_code` | | `certification.failed` | The certification could not be issued (the charge is refunded) | `certification_id`, `channel_id`, `status`, `event_count`, `incident_id` | | `balance.low` | The balance drops below an alert threshold (once per crossing) | `threshold_id`, `threshold`, `balance` | | `balance.exhausted` | The first event is registered as unpaid | `channel_id` | | `events.unpaid_limit_reached` | The unpaid-events limit is reached: the API rejects registrations with `402` | `unpaid_limit`, `unpaid_events`, `unpaid_credits` | | `consumption.limit_reached` | A consumption limit reaches its maximum for the period | `limit_id`, `scope`, `channel_id`, `key_id`, `period`, `period_key`, `max_events`, `action` | | `recharge.auto_completed` | An automatic top-up was charged and its credits are in the balance | `recharge_id`, `pack_id`, `credits`, `amount`, `currency`, `threshold`, `balance` | | `recharge.auto_failed` | An automatic top-up failed and was turned off | `recharge_id`, `pack_id`, `credits`, `amount`, `currency`, `threshold`, `reason`, `payment_url`, `auto_recharge_disabled` | | `webhook.test` | You request a test delivery in the console | `endpoint_id`, `message` | ```json { "id": "ntc_…", "type": "balance.low", "created_at": "2026-09-28T07:00:16.526317Z", "mode": "live", "data": { "threshold_id": "thr_…", "threshold": 1000, "balance": 999 } } ``` Credits are integers and timestamps are UTC with six fractional digits. Each field is described in the API reference (`Webhook…Data` schemas). We may add fields to `data` over time: ignore the ones you do not know. Every delivery is **signed** with the endpoint secret and retried with exponential backoff for up to 24 hours (1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h). The delivery log lives in the console; an endpoint that keeps failing is disabled and we notify you by email. How to verify a delivery before processing it: 1. Read the `X-SC-Timestamp` (Unix seconds) and `X-SC-Signature` (`v1=`) headers. 2. Compute HMAC-SHA256 with your secret (`whsec_…`) over `.` and compare it with `` in constant time. 3. Reject timestamps older than 5 minutes. 4. Reply with any 2xx; de-duplicate by `X-SC-Webhook-Id` (the envelope `id`, the same on retries). ## 8. Pricing and balance - **Prepaid, by volume**: buy **credit packs** in the console; each registered event consumes **one credit**. No plans, no lock-in, no surprises. [See pricing](/pricing). - [`GET /account/balance`](/docs/reference.md#getAccountBalance) returns the balance in credits. - Test events cost nothing. - Set **balance alerts** and, if you want, **consumption limits** per channel or key (warn or block): you control the spend. - Or turn on **automatic top-up**: "when the balance drops below X credits, buy pack Y" with your default payment method at Safe Creative. We notify you of each top-up by email and webhook (`recharge.auto_completed`); if one fails, it is turned off without losing its settings and we notify you (`recharge.auto_failed`) so you can turn it back on once it is sorted out. ### 8.1 What if the balance runs out? Your sequence **does not break**: events arriving with an exhausted balance are still received, sealed and chained, marked **unpaid** — up to a limit. While you have unpaid events: - no certifications can be issued (`account_not_operational` — "account not operational"); - if the unpaid limit is reached, new registrations are rejected (`unpaid_limit_exceeded`). When you top up, unpaid events are **charged automatically** and everything returns to normal — none of their proof elements changes. Recommendation: configure `balance.low` and never get here. If a purchase is reverted after payment (a refund or a chargeback), its credits are deducted even if you had already spent them. The balance may then be negative: until the next purchase covers it, the account is not operational and new live events are received as unpaid, as above. ### 8.2 Unused accounts An account that goes **a year** without registering events, requesting certifications (with a live or test key) or buying credits is **disabled**: the API rejects every call (`403 account_disabled`) and the console is unavailable. We email you 30 and 7 days before; any one of those operations restarts the period. Your data and certifications are kept, and issued certifications can still be verified. To reactivate it, contact support. ## 9. Errors and retries Uniform format: `code`, `message`, `doc_url`. Branch on `code`, never on `message`; `doc_url` leads to its row in the [error code catalog](/docs/errors), with its HTTP status, meaning and the operations that can return it. Quick guide: | You get | What to do | |---|---| | `400 validation_error` | Check the body and the channel's modality. Don't retry as-is. | | `401 invalid_key` / `revoked_key` | Wrong or revoked key: check your configuration. | | `402` (all three codes) | Balance matter: top up or check unpaid events. Retrying won't help. | | `403 account_suspended` | Account suspended by Safe Creative: registrations and certifications are rejected; queries keep working. Contact support. | | `403 channel_suspended` | Channel suspended by Safe Creative: every call with its keys is rejected, queries included. Contact support. | | `403 account_disabled` | Account disabled (after a year without operations or by Safe Creative): every call is rejected. Contact support to reactivate it. | | `409 client_ref_conflict` | You reused a `client_ref` with different content: a bug in your emitter. | | `422 limit_blocked` | Your own consumption limit, set to "block". | | `429 rate_limited` | Wait `Retry-After` and retry. Lower your sustained rate. | | `503 temporarily_unavailable` | Transient storage failure (e.g. during a database failover): nothing was acknowledged. Wait `Retry-After` and retry with the same `client_ref`. | | `5xx internal` | Retry with exponential backoff and the same `client_ref` (it won't duplicate). Include `incident_id` when contacting support. | Rate limits: 50 sustained requests/second per live key (bursts ×4) and 10 per test key. Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds). Error messages come in English or, with `Accept-Language: es`, in Spanish. ## 10. Test mode Everything in this guide works the same with an `sc_test_` key: same API, same contract, separate chain, zero cost and **no probative value** (reports carry the "TEST" watermark and verify in amber). Test data is purged after 30 days. To exercise the hard cases without touching your real balance, force scenarios with the `X-SC-Test-Scenario: unpaid | unpaid_limit | rate_limited` header on test calls. Test mode is for integrating and testing, not for real workloads, and it has limits per account and day (UTC): | Limit | Value | |---|---| | Test events | 10,000 per day | | Declared content in test (JSON or file) | 100 MB per day | | Test certifications | 20 per day, of at most 10,000 events each | | Active test keys | 5 per channel | | Requests with a test key | 10 per second | Past a daily limit the API answers `429 test_quota_exceeded` with `Retry-After` until the next day; what is rejected is not registered. A larger test certification answers `400`. ## 11. Integration recipes Recommended configurations per use case, with sample event formats and an indicative regulatory anchor (not legal advice). Each recipe ships as its own page on the documentation site with complete examples; below, a summary of all of them and, in full, the first one with a date on the calendar. - **Proof of diligence in vulnerability and incident management** (Cyber Resilience Act, art. 14 — reporting deadlines from 2026-09-11 —; NIS2, arts. 21 and 23; DORA, arts. 17-19): hash-only modality, one channel per protected system, one event per security-tool result. Developed in [§11.1](/docs/11-integration-recipes.md#recipe-proof-of-diligence-in-vulnerability-and-incident-management). - **AI agent and system logs** (EU AI Act, high-risk systems; obligation postponed by the Digital Omnibus to 2027-12-02 and 2028-08-02 depending on the annex): content modality, one token per agent action, formatted in line with the IETF draft on agent audit trails ([§11.2](/docs/11-integration-recipes.md#note-on-ai-agent-logs)). - **Whistleblowing channels** (probative deadlines with confidentiality): hash-only modality, one event per case milestone. - **Maintenance and periodic inspections**: content modality, one event per closed work report — what you prove is sustained diligence. - **Changes in critical systems** (DORA and similar): hash-only or content modality, one event per deployment or relevant configuration change. ### 11.1 Recipe: proof of diligence in vulnerability and incident management **Who it is for.** Software manufacturers subject to the Cyber Resilience Act, entities under NIS2 or DORA, and the integrators or managed security providers who run AI security tooling for them: agents that scan and fix code (Codex Security, Claude Security, GitHub Copilot Autofix, Semgrep, Snyk, CodeQL), autonomous penetration testing, automated security operations centres. All of them produce facts at scale and attest those facts themselves; the channel adds what they cannot give themselves: a date and integrity from a third party that neither the manufacturer, nor its security vendor, nor the model provider controls. **What is proved.** Not that the patch is correct or the scan was complete — you and your tool assert that — but that each fact was declared on a date with legal presumption and that the sequence has not been recomposed afterwards: that the scan existed before the incident, that the vulnerability was known on day X, that the fix was verified on day Y, that the notification to the authority went out within the deadline. Legal deadlines run from the moment you "become aware": proving that instant is proving compliance. **Recommended channel configuration.** - One channel per protected system or product. - **Hash-only** modality: vulnerability details must not leave your perimeter before coordinated disclosure. You keep the files (SARIF, reports, acknowledgements) and declare their digest; if you ever need to produce them, the sealed digest anchors them to their date. - Declarant: the manufacturer or the obligated entity. If you operate as an integrator, designate your customer. - Supplementary information: which system, which tools and which phases are declared, and the identifier convention you use (it is sealed in the configuration, before the facts). **Typical events.** In hash-only mode an event carries `client_ref`, `hash` and `hash_alg` ([§4.1](/docs/4-registering-events.md#the-call)); the type of fact and its identifier go in the `client_ref`, under a convention you declare in the channel's supplementary information. | Cycle phase | Recommended `client_ref` | Digest of | |---|---|---| | Discover | `scan.completed//` | Scan manifest or SARIF file | | Discover | `finding.opened//` | Finding record (identifier, severity, component) | | Validate | `finding.validated/…` · `finding.dismissed/…` | Validation report | | Assign | `finding.assigned/…` | Ticket created | | Fix | `fix.deployed///` | Deployment confirmation and change identifier | | Verify | `fix.verified///` | Result of the re-run scan | | Notify | `notice.early/…` · `notice.full/…` · `report.final/…` | Acknowledgement from the reporting platform (ENISA, CSIRT) | **How to connect: the three usual routes.** No tool needs to cooperate; all of them export in standard formats, fire webhooks, or run in your continuous integration. *Route 1 — a continuous-integration step that declares the scan's digest.* Works with any tool that produces SARIF. Keep `results.sarif` next to the revision: it is the original the digest anchors. ``` # .github/workflows/security.yml — after the step that produces results.sarif - name: Declare the scan to Safe Creative Platform env: SC_KEY: ${{ secrets.SC_EVENTS_KEY }} # channel key (live or test) run: | HASH=$(sha256sum results.sarif | cut -d' ' -f1) curl -sS --retry 5 --retry-all-errors -X POST https://platform.safecreative.org/v1/events \ -H "Authorization: Bearer $SC_KEY" -H "Content-Type: application/json" \ -d "{\"client_ref\":\"scan.completed/$GITHUB_REPOSITORY/$GITHUB_SHA\", \"hash\":\"$HASH\",\"hash_alg\":\"sha-256\", \"occurred_at\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" ``` *Route 2 — a GitHub webhook receiver.* GitHub sends a `code_scanning_alert` webhook on every state change of an alert: created, fixed, closed, reopened. It is the cheapest hook in the whole family (it also covers alerts uploaded by Semgrep, Snyk or CodeQL). Node 20, no dependencies: ``` import { createServer } from 'node:http'; import { createHash, createHmac, timingSafeEqual } from 'node:crypto'; const SC_KEY = process.env.SC_EVENTS_KEY, GH_SECRET = process.env.GH_WEBHOOK_SECRET; createServer(async (req, res) => { let body = ''; for await (const chunk of req) body += chunk; // 1) verify GitHub's signature first const expected = 'sha256=' + createHmac('sha256', GH_SECRET).update(body).digest('hex'); const received = String(req.headers['x-hub-signature-256'] || ''); if (expected.length !== received.length || !timingSafeEqual(Buffer.from(expected), Buffer.from(received))) { res.writeHead(401); res.end(); return; } const hook = JSON.parse(body); // action: created | fixed | closed_by_user | reopened … archive(body, req.headers['x-github-delivery']); // keep the JSON: it is the original the digest anchors // 2) declare the digest to the channel (idempotent by client_ref: the delivery id prevents duplicates) const r = await fetch('https://platform.safecreative.org/v1/events', { method: 'POST', headers: { 'Authorization': 'Bearer ' + SC_KEY, 'Content-Type': 'application/json' }, body: JSON.stringify({ client_ref: 'finding.' + hook.action + '/' + hook.repository.full_name + '/' + hook.alert.number + '/' + req.headers['x-github-delivery'], hash: createHash('sha256').update(body).digest('hex'), hash_alg: 'sha-256', occurred_at: hook.alert.updated_at || hook.alert.created_at }) }); res.writeHead(r.ok ? 204 : 500); res.end(); // on failure, GitHub redelivers }).listen(8080); ``` *Route 3 — a generic webhook receiver* (Claude Security, SOC platforms, penetration-testing tools). Python 3.11, no dependencies. Adjust field names to your tool's schema and verify its signature if it offers one: ``` import hashlib, json, os, urllib.request from http.server import BaseHTTPRequestHandler, HTTPServer SC_KEY = os.environ["SC_EVENTS_KEY"] class Receiver(BaseHTTPRequestHandler): def do_POST(self): raw = self.rfile.read(int(self.headers["Content-Length"])) hook = json.loads(raw) archive(raw) # keep the original the digest anchors kind = hook.get("event", "finding.updated") # e.g. finding.validated, fix.verified ref = f'{kind}/{hook.get("project", "-")}/{hook.get("id", hashlib.sha256(raw).hexdigest()[:16])}' event = {"client_ref": ref, "hash": hashlib.sha256(raw).hexdigest(), "hash_alg": "sha-256"} if "timestamp" in hook: event["occurred_at"] = hook["timestamp"] request = urllib.request.Request( "https://platform.safecreative.org/v1/events", data=json.dumps(event).encode(), method="POST", headers={"Authorization": f"Bearer {SC_KEY}", "Content-Type": "application/json"}) urllib.request.urlopen(request, timeout=10) # on error, let the tool retry self.send_response(204); self.end_headers() HTTPServer(("", 8080), Receiver).serve_forever() ``` The rule in [§4.2](/docs/4-registering-events.md#idempotency-retry-without-fear) applies to all three routes: same `client_ref` on retries, exponential backoff, and a local outbound queue if the process cannot afford to drop declarations. Request the certification when you need it — for an inspection, a claim or a lawsuit — and the report will attest the integrity of the period's sequence; the evidence archive lets anyone match each digest against the original you keep. **Indicative regulatory anchor.** Cyber Resilience Act, art. 14 (early warning within 24 hours, notification within 72 hours, final report within 14 days or one month; from 2026-09-11) · NIS2, arts. 21 and 23 · DORA, arts. 17-19 and 24 ff. None of these rules requires a third party: they require demonstrable diligence, and the moment you "became aware" is what the channel leaves proved. ### 11.2 Note on AI agent logs If your case is logging agent actions, align the token with the IETF draft `draft-sharif-agent-audit-trail`: `record_id`, `session_id`, `agent_id`, `action_type`, `outcome`, `record_phase` and, if you keep your own chain, `prev_hash`. Use `record_id` as the `client_ref`. That model requires, from a certain assurance level up, that logging be done by a **component independent of the agent** with an **external timestamp**: that component is the channel, and each event's reception date (`received_at`) is covered by the qualified timestamp of its segment. The agent does not need to sign anything for the sequence to carry a third-party date. ``` { "client_ref": "rec_01J9ZM3T7Q8KX2", "payload": { "record_id": "rec_01J9ZM3T7Q8KX2", "session_id": "ses_44a1", "agent_id": "case-handler-v3", "action_type": "case.update", "outcome": "success", "record_phase": "post", "prev_hash": "sha256:6b1f0c6e…94a7d", "target": "EXP-4411" }, "occurred_at": "2026-09-04T09:41:07Z" } ``` ## 12. FAQ **Do I need to buy a safecreative.org plan?** No. You only need a safecreative.org account, which is your identity to sign in: any type works, the free one included, and you don't have to buy any plan. This service is contracted and billed separately, and grants (and receives) no safecreative.org services. **Does my event content leave my perimeter?** Only if you choose the content modality. With hash-only, you send the cryptographic digest and the data never leaves your systems; if one day you must produce the original, its sealed digest anchors it to its date. **Can I change the channel configuration?** Yes: a **new version** is created, also sealed. No version is ever rewritten, and every certification states which version was in force for each segment. That is what turns your configuration into a commitment made before the facts. **What legal weight does this carry?** In the EU, a qualified timestamp is presumed accurate in its date and time, and guarantees that what was sealed has not changed: whoever denies it has to prove it (eIDAS Regulation, art. 41.2). In Spain, when a qualified trust service is used, procedural law reinforces that presumption (art. 326.4 LEC). Outside the EU there is no legal presumption for the qualified timestamp: the proof rests on its verifiable integrity and on custody by an independent third party. No rule forces you to use a third party: it is a solid way to be able to prove it. **White label?** No. Reports carry Safe Creative's brand and signature — precisely the value neither you nor your customer can manufacture. **How does a third party verify my report?** On the public verification page: with the code printed on the report, or by uploading the file (which additionally confirms it has not been altered). No account, no cost. **How is this different from an audit trail signed by my own system?** In who attests. "Tamper-evident" audit trails that sign receipts with the operator's key, chain digests and add an RFC 3161 timestamp from a free or commercial authority are verifiable — but they are attested by the interested party itself, and a non-qualified timestamp carries no legal presumption under article 41.2 of the eIDAS Regulation. Here Safe Creative, a third party independent of the parties, holds and chains your declarations; the timestamp is issued by an eIDAS qualified provider, the configuration is committed before the facts, and the report is signed by Safe Creative. If you already run your own chain, keep it: declare the digest of each receipt or of each checkpoint to the channel and turn it into third-party evidence. **Can an AI agent declare directly, or send OpenTelemetry traces?** Today, through the REST API from the agent's code or via a receiver ([§11.1](/docs/11-integration-recipes.md#recipe-proof-of-diligence-in-vulnerability-and-incident-management), route 3). An MCP server (so the agent declares as a tool call) and an OpenTelemetry trace receiver are planned as later layers on the same API; they do not change the contract. To have a coding agent write that integration for you, see [§13](/docs/13-integrating-with-ai-agents.md). ## 13. Integrating with AI agents If you write your integration with an AI coding agent (Claude Code, Codex, GitHub Copilot, Cursor, Gemini CLI, Windsurf…), give it what it needs to get the details right: the rules of [§4.2](/docs/4-registering-events.md#idempotency-retry-without-fear), the error codes of [§9](/docs/9-errors-and-retries.md) and webhook verification are exactly where generic code fails. ### 13.1 The integration skill The **Safe Creative Platform skill** follows the open [Agent Skills](https://agentskills.io) format, which most coding agents load. It explains the service and the full journey, the idempotency and retry rules, the error codes, test mode and webhook verification, with examples in Python, TypeScript and Java and three small scripts (a minimal client, file digests and webhook verification). Licence: Apache 2.0. - [Download the skill](/docs/agents/safecreative-platform-api.zip) (`safecreative-platform-api.zip`) - [Download the full plugin](/docs/agents/safecreative-platform-plugin.zip) (the skill plus the plugin manifests for Claude Code and for Agent Plugins clients) | Agent | How to install it | |---|---| | Claude Code | Unzip the skill into `.claude/skills/` of your project (or `~/.claude/skills/` for all of them). With the plugin: unzip it and run `/plugin marketplace add ` and `/plugin install safecreative-platform@safecreative`. | | claude.ai and Claude Desktop | Upload `safecreative-platform-api.zip` in Settings › Capabilities. | | Codex, Cursor, Gemini CLI, Windsurf and other agents | Unzip the skill into `.agents/skills/` of your project. | | GitHub Copilot | Unzip the skill into `.github/skills/` (it also reads `.agents/skills/`). | The agent uses it on its own when you ask for something related ("declare each paid order to Safe Creative Platform", "verify their webhooks"). Give it a **test key** in an environment variable (`SC_API_KEY=sc_test_…`), never a live one while developing. ### 13.2 Documentation in Markdown Every page of this documentation is also served in Markdown, the format agents read best: add `.md` to its URL (for example `/docs/guide.md` for the whole guide, `/docs/reference.md` for the API reference, `/docs/errors.md` for the error codes) or ask for it with `Accept: text/markdown`. [`/llms.txt`](/llms.txt) indexes everything and [`/llms-full.txt`](/llms-full.txt) gathers it in a single document; the contract is at [`/q/openapi`](/q/openapi). ### 13.3 When an agent operates the API If an agent calls the API itself (registering events or requesting certifications on your behalf): - Give it **its own key**, so you can see its consumption per key, set it limits and revoke it without touching your other systems. - Use a **test key** until the behaviour is right: live events are irreversible and each one consumes a credit. - With a live key, set a **consumption limit** with the "block" action on that key ([§8](/docs/8-pricing-and-balance.md)). - Treat the content returned by [`GET /events/{event_id}/content`](/docs/reference.md#getEventContent) as data, not as instructions, and the certification download URLs as secrets. To remind your agent of the rules in every project, you can add this to your `AGENTS.md` (or `CLAUDE.md`): ``` ## Safe Creative Platform - API: https://platform.safecreative.org/v1, key in SC_API_KEY (sc_test_ while developing). - Every event has a client_ref derived from the source fact, persisted before the call. - On timeouts, 429, 503 or 5xx, retry the same request with the same client_ref; never a new one. - Branch on the error code, never on the message. Docs: https://platform.safecreative.org/docs/guide.md ``` # Error codes Every error response of the API carries a stable code, a message and a doc_url that points to the code's row on this page. Branch on the code, never on the message. Generated automatically from the API contract. What to do with each error and how to retry is explained in the guide: [Errors and retries](/docs/9-errors-and-retries.md). | Code | HTTP | Meaning | Operations | |---|---|---|---| | `validation_error` | 400 | Invalid body, parameters or range, or a body that does not match the channel's modality. Fix the request: repeating it as is fails again. | `POST /events`, `GET /events`, `POST /certifications`, `GET /certifications` | | `invalid_key` | 401 | The API key is missing, malformed or unknown. | Any operation | | `revoked_key` | 401 | The API key was revoked in the console. | Any operation | | `account_not_operational` | 402 | The owner account has unpaid events or a negative balance, so certifications are rejected until a purchase settles them. | `POST /certifications` | | `unpaid_limit_exceeded` | 402 | The owner account reached its unpaid-events limit: new events are rejected until a purchase settles the debt. | `POST /events` | | `insufficient_balance` | 402 | Not enough credits for the certification issuance fee, when one is set. | `POST /certifications` | | `not_found` | 404 | The resource does not exist on this channel, or the event has no content to download (hash-only modality). | `GET /events/{event_id}`, `GET /events/{event_id}/content`, `GET /certifications/{certification_id}` | | `client_ref_conflict` | 409 | The `client_ref` was already used on this channel with different content; the original event is kept. It points to a bug in the emitter. | `POST /events` | | `payload_too_large` | 413 | The body is over the modality limit (256 KB of JSON, 10 MB per file). | `POST /events` | | `limit_blocked` | 422 | A consumption limit set to block in the console was reached: registrations are rejected until its next period (UTC day or month). | `POST /events` | | `rate_limited` | 429 | Too many requests with this key: wait `Retry-After` seconds and lower the sustained rate. | Any operation | | `test_quota_exceeded` | 429 | With a test key, a daily test-mode limit (events, declared content or certifications) was reached: wait `Retry-After`, until the next UTC day. | Any operation | | `internal` | 5xx | Unexpected server error (HTTP 5xx), with `incident_id`. Retry with exponential backoff (with the same `client_ref` when registering events) and quote `incident_id` to support if it persists. | Any operation | | `temporarily_unavailable` | 503 | Transient storage failure; nothing was acknowledged. Retry after `Retry-After` (with the same `client_ref` when registering events). | `POST /events`, `POST /certifications` | | `account_suspended` | 403 | Safe Creative suspended the owner account: event registration and certifications are rejected, queries keep working. Contact support. | Any operation | | `channel_suspended` | 403 | Safe Creative suspended the channel: every call with its keys is rejected, queries included. Contact support. | Any operation | | `account_disabled` | 403 | The owner account is disabled (after a year without operations, or by Safe Creative): every call is rejected until support reactivates it. | Any operation | # Safe Creative Platform API version 1.0-draft. Generated automatically from the OpenAPI contract of the API. [openapi.yaml](https://platform.safecreative.org/q/openapi) Event registration with qualified-trust evidence, operated by Safe Creative as an independent trusted third party. **What the service certifies:** that a declaration was received, exactly when, and that it has not been altered since — never the truth of the declared fact. Base URL: `https://platform.safecreative.org/v1`. **Modes:** every API key is either `live` (`sc_live_…`) or `test` (`sc_test_…`). Test keys hit the same endpoints with the same contract, consume no balance, produce no probative value, and write to a separate per-channel chain. Test mode has daily limits per account (UTC): 10,000 events, 100 MB of declared content and 20 certifications, each of at most 10,000 events; past a daily limit the API answers `429 test_quota_exceeded` with `Retry-After` until the next day. ### Conventions - *Rate limits* per API key: 50 req/s sustained for live keys (bursts ×4; an account may have its own limit), 10 req/s for test keys. Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds); a `429 rate_limited` adds `Retry-After`. - *Temporary unavailability:* an event is only acknowledged once it is durably stored and replicated. During a transient storage failure any operation may answer `503 temporarily_unavailable` with `Retry-After`: retry, with the same `client_ref` when registering events. - *Language:* error `message` in English by default, Spanish with `Accept-Language: es`. - *Forced test scenarios:* with a test key, `X-SC-Test-Scenario: unpaid | unpaid_limit | rate_limited` reproduces the documented 402/429 responses without touching real state. - *Webhook signature:* see `WebhookEnvelope`. ### Formats - *Timestamps in responses and webhooks* are UTC, RFC 3339 with the `Z` suffix and exactly six fractional digits (microseconds, the precision they are stored with), e.g. `2026-09-28T07:00:16.526317Z`. The acknowledgment of `POST /events` returns `received_at` exactly as it is registered and later certified. - *Timestamps in requests* (`occurred_at`, certification ranges, query filters) accept any RFC 3339 date-time with an offset (`Z` or `±hh:mm`) and up to nine fractional digits; they are normalized to UTC and truncated to microseconds. - *Hashes* are lowercase hexadecimal. *Credits* and *sequence numbers* are integers. ## Operations ### `POST /events` — Declare (register) an event operationId: `registerEvent` Registers one declared event on the channel identified by the API key. The synchronous acknowledgment is the client's receipt: it commits the event's position in the channel sequence. **Body and modality:** the body must match the channel's modality (see `GET /channel`): `NewEventContent` or a file (`multipart/form-data`) on a content channel, `NewEventHashOnly` on a hash-only channel; otherwise `400 validation_error`. **Idempotency:** `client_ref` must be unique per channel. Retrying with the same `client_ref` and identical content returns `200` with the original event; same `client_ref` with different content returns `409` and never overwrites. **Retries:** when the outcome is unknown (timeout, network error, `429`, `503` or another `5xx`), retry with the same `client_ref` and the same content: you get the original event, never a duplicate. Never generate a new `client_ref` to retry. A live event can never be deleted or modified. **Exhausted balance:** events are still registered, sealed and chained with `status: unpaid`, up to the back-office unpaid limit; past it, `402 unpaid_limit_exceeded`. **Body** - `application/json`: `NewEventContent` | `NewEventHashOnly` (one of these schemas) - `multipart/form-data`: `NewEventFile` **Responses** | Status | Description | Body | |---|---|---| | `201` | Event registered (new). | `application/json`: `Event` | | `200` | Idempotent replay — same `client_ref`, identical content; original event returned. | `application/json`: `Event` | | `400` | Invalid body or parameters, or modality mismatch (`validation_error`). | `application/json`: `Error` | | `401` | Missing, invalid or revoked key (`invalid_key` / `revoked_key`). | `application/json`: `Error` | | `402` | Unpaid-events limit reached (`unpaid_limit_exceeded`). | `application/json`: `Error` | | `403` | Owner account suspended by Safe Creative (`account_suspended`: event registration and certifications are rejected, queries keep working), channel suspended by Safe Creative (`channel_suspended`: every call with its keys is rejected) or owner account disabled (`account_disabled`: every call is rejected; accounts are disabled after a year without registering events, requesting certifications or buying credits, and support reactivates them). Data and chains are untouched. | `application/json`: `Error` | | `409` | `client_ref` already used on this channel with different content (`client_ref_conflict`). | `application/json`: `Error` | | `413` | Body over the modality limit (`payload_too_large`). | `application/json`: `Error` | | `422` | Blocked by a user-defined consumption limit set to "block" (`limit_blocked`). | `application/json`: `Error` | | `429` | Rate limit exceeded (`rate_limited`) or, with a test key, a test-mode daily limit reached (`test_quota_exceeded`); includes `Retry-After`. | `application/json`: `Error` | | `503` | Temporarily unavailable (`temporarily_unavailable`): a transient storage failure, e.g. during a database failover. Nothing was acknowledged: retry after `Retry-After` with the same `client_ref`, which idempotency resolves. | `application/json`: `Error` | ### `GET /events` — List events of the channel operationId: `listEvents` **Parameters** | Name | In | Type | Description | |---|---|---|---| | `from` | query | `string (date-time)` | Received-at lower bound (inclusive). RFC 3339 date-time with an offset (`Z` or `±hh:mm`); normalized to UTC and truncated to microseconds (see [Formats](#formats)). | | `to` | query | `string (date-time)` | Received-at upper bound (exclusive). RFC 3339 date-time with an offset (`Z` or `±hh:mm`); normalized to UTC and truncated to microseconds (see [Formats](#formats)). | | `seq_from` | query | `integer` (minimum 1) | First sequence number, inclusive. | | `seq_to` | query | `integer` (minimum 1) | Last sequence number, inclusive. | | `status` | query | `string: registered · unpaid · test` | Only events in this status: `registered` (live, charged), `unpaid` (live, pending payment) or `test` (every event of a test key). | | `limit` | query | `integer` (minimum 1, maximum 500, default 100) | Page size. | | `cursor` | query | `string` | Opaque cursor: the `next_cursor` of the previous page. Repeat the request with the same filters. | **Responses** | Status | Description | Body | |---|---|---| | `200` | Paginated event list, ordered by sequence. | `application/json`: `{ data: Event[], next_cursor: string \| null }` | | `400` | Invalid body or parameters, or modality mismatch (`validation_error`). | `application/json`: `Error` | | `401` | Missing, invalid or revoked key (`invalid_key` / `revoked_key`). | `application/json`: `Error` | | `403` | Owner account suspended by Safe Creative (`account_suspended`: event registration and certifications are rejected, queries keep working), channel suspended by Safe Creative (`channel_suspended`: every call with its keys is rejected) or owner account disabled (`account_disabled`: every call is rejected; accounts are disabled after a year without registering events, requesting certifications or buying credits, and support reactivates them). Data and chains are untouched. | `application/json`: `Error` | | `429` | Rate limit exceeded (`rate_limited`) or, with a test key, a test-mode daily limit reached (`test_quota_exceeded`); includes `Retry-After`. | `application/json`: `Error` | ### `GET /events/{event_id}` — Get one event with its full proof elements operationId: `getEvent` **Parameters** | Name | In | Type | Description | |---|---|---|---| | `event_id` | path (required) | `string` | Event id (`evt_…`), as returned by `POST /events`. An event of another channel or mode does not exist for this key. | **Responses** | Status | Description | Body | |---|---|---| | `200` | The event. | `application/json`: `Event` | | `401` | Missing, invalid or revoked key (`invalid_key` / `revoked_key`). | `application/json`: `Error` | | `403` | Owner account suspended by Safe Creative (`account_suspended`: event registration and certifications are rejected, queries keep working), channel suspended by Safe Creative (`channel_suspended`: every call with its keys is rejected) or owner account disabled (`account_disabled`: every call is rejected; accounts are disabled after a year without registering events, requesting certifications or buying credits, and support reactivates them). Data and chains are untouched. | `application/json`: `Error` | | `404` | Resource not found on this channel (`not_found`). | `application/json`: `Error` | | `429` | Rate limit exceeded (`rate_limited`) or, with a test key, a test-mode daily limit reached (`test_quota_exceeded`); includes `Retry-After`. | `application/json`: `Error` | ### `GET /events/{event_id}/content` — Download the content held for an event operationId: `getEventContent` Content modality only (`modality: content`): returns the declared content exactly as Safe Creative holds it — the JSON in its canonical form, the one that was hashed, or the file, with its original name in `Content-Disposition`. Its digest is the event's `hash`. A hash-only event has no content: `404 not_found`. **Parameters** | Name | In | Type | Description | |---|---|---|---| | `event_id` | path (required) | `string` | Event id (`evt_…`), as returned by `POST /events`. An event of another channel or mode does not exist for this key. | **Responses** | Status | Description | Body | |---|---|---| | `200` | The content — `application/json` for JSON content, `application/octet-stream` for a file. | `application/json`: `object`; `application/octet-stream`: `string` | | `401` | Missing, invalid or revoked key (`invalid_key` / `revoked_key`). | `application/json`: `Error` | | `403` | Owner account suspended by Safe Creative (`account_suspended`: event registration and certifications are rejected, queries keep working), channel suspended by Safe Creative (`channel_suspended`: every call with its keys is rejected) or owner account disabled (`account_disabled`: every call is rejected; accounts are disabled after a year without registering events, requesting certifications or buying credits, and support reactivates them). Data and chains are untouched. | `application/json`: `Error` | | `404` | Resource not found on this channel (`not_found`). | `application/json`: `Error` | | `429` | Rate limit exceeded (`rate_limited`) or, with a test key, a test-mode daily limit reached (`test_quota_exceeded`); includes `Retry-After`. | `application/json`: `Error` | ### `GET /channel` — Get the channel and its sealed configuration versions operationId: `getChannel` **Responses** | Status | Description | Body | |---|---|---| | `200` | Channel with current configuration and version history. | `application/json`: `Channel` | | `401` | Missing, invalid or revoked key (`invalid_key` / `revoked_key`). | `application/json`: `Error` | | `403` | Owner account suspended by Safe Creative (`account_suspended`: event registration and certifications are rejected, queries keep working), channel suspended by Safe Creative (`channel_suspended`: every call with its keys is rejected) or owner account disabled (`account_disabled`: every call is rejected; accounts are disabled after a year without registering events, requesting certifications or buying credits, and support reactivates them). Data and chains are untouched. | `application/json`: `Error` | | `429` | Rate limit exceeded (`rate_limited`) or, with a test key, a test-mode daily limit reached (`test_quota_exceeded`); includes `Retry-After`. | `application/json`: `Error` | ### `GET /account/balance` — Balance and operational status of the channel's owner account operationId: `getAccountBalance` Lets client systems self-monitor (low balance, unpaid events, operational status). **Responses** | Status | Description | Body | |---|---|---| | `200` | Balance snapshot. | `application/json`: `AccountBalance` | | `401` | Missing, invalid or revoked key (`invalid_key` / `revoked_key`). | `application/json`: `Error` | | `403` | Owner account suspended by Safe Creative (`account_suspended`: event registration and certifications are rejected, queries keep working), channel suspended by Safe Creative (`channel_suspended`: every call with its keys is rejected) or owner account disabled (`account_disabled`: every call is rejected; accounts are disabled after a year without registering events, requesting certifications or buying credits, and support reactivates them). Data and chains are untouched. | `application/json`: `Error` | | `429` | Rate limit exceeded (`rate_limited`) or, with a test key, a test-mode daily limit reached (`test_quota_exceeded`); includes `Retry-After`. | `application/json`: `Error` | ### `POST /certifications` — Request a certification of a range (asynchronous) operationId: `requestCertification` Starts the issuance of the signed certification reports (one in Spanish and one in English) plus their evidence archive for a range of this channel, by dates **or** by sequence numbers. Rejected with `402 account_not_operational` while the owner account has any unpaid event ("cuenta sin saldo operativo"). **Asynchronous:** the `202` returns the certification in `processing`. Poll `GET /certifications/{certification_id}` (every few seconds at most) until `status` is `completed` or `failed`, or subscribe in the console to the `certification.completed` and `certification.failed` webhooks. **Body** - `application/json`: `NewCertification` **Responses** | Status | Description | Body | |---|---|---| | `202` | Accepted; issuance runs asynchronously. | `application/json`: `Certification` | | `400` | Invalid body or parameters, or modality mismatch (`validation_error`). | `application/json`: `Error` | | `401` | Missing, invalid or revoked key (`invalid_key` / `revoked_key`). | `application/json`: `Error` | | `402` | `account_not_operational` (unpaid events or negative balance) or `insufficient_balance` (issuance fee, if enabled). | `application/json`: `Error` | | `403` | Owner account suspended by Safe Creative (`account_suspended`: event registration and certifications are rejected, queries keep working), channel suspended by Safe Creative (`channel_suspended`: every call with its keys is rejected) or owner account disabled (`account_disabled`: every call is rejected; accounts are disabled after a year without registering events, requesting certifications or buying credits, and support reactivates them). Data and chains are untouched. | `application/json`: `Error` | | `429` | Rate limit exceeded (`rate_limited`) or, with a test key, a test-mode daily limit reached (`test_quota_exceeded`); includes `Retry-After`. | `application/json`: `Error` | | `503` | Temporarily unavailable (`temporarily_unavailable`): a transient storage failure, e.g. during a database failover. Nothing was acknowledged: retry after `Retry-After` with the same `client_ref`, which idempotency resolves. | `application/json`: `Error` | ### `GET /certifications` — List certifications of the channel operationId: `listCertifications` **Parameters** | Name | In | Type | Description | |---|---|---|---| | `status` | query | `string: processing · completed · failed` | Only certifications in this status. | | `limit` | query | `integer` (minimum 1, maximum 100, default 50) | Page size. | | `cursor` | query | `string` | Opaque cursor: the `next_cursor` of the previous page. Repeat the request with the same filters. | **Responses** | Status | Description | Body | |---|---|---| | `200` | Paginated list, newest first. | `application/json`: `{ data: Certification[], next_cursor: string \| null }` | | `400` | Invalid body or parameters, or modality mismatch (`validation_error`). | `application/json`: `Error` | | `401` | Missing, invalid or revoked key (`invalid_key` / `revoked_key`). | `application/json`: `Error` | | `403` | Owner account suspended by Safe Creative (`account_suspended`: event registration and certifications are rejected, queries keep working), channel suspended by Safe Creative (`channel_suspended`: every call with its keys is rejected) or owner account disabled (`account_disabled`: every call is rejected; accounts are disabled after a year without registering events, requesting certifications or buying credits, and support reactivates them). Data and chains are untouched. | `application/json`: `Error` | | `429` | Rate limit exceeded (`rate_limited`) or, with a test key, a test-mode daily limit reached (`test_quota_exceeded`); includes `Retry-After`. | `application/json`: `Error` | ### `GET /certifications/{certification_id}` — Get a certification's status and download links operationId: `getCertification` **Parameters** | Name | In | Type | Description | |---|---|---|---| | `certification_id` | path (required) | `string` | Certification id (`CERT-2026-0000001`, or `CERT-TEST-2026-0000001` in test mode), as returned by `POST /certifications`. | **Responses** | Status | Description | Body | |---|---|---| | `200` | The certification. When `completed`, includes download URLs (report PDFs and evidence ZIP; URLs expire after 7 days and can be regenerated by re-fetching) and the public verification code. | `application/json`: `Certification` | | `401` | Missing, invalid or revoked key (`invalid_key` / `revoked_key`). | `application/json`: `Error` | | `403` | Owner account suspended by Safe Creative (`account_suspended`: event registration and certifications are rejected, queries keep working), channel suspended by Safe Creative (`channel_suspended`: every call with its keys is rejected) or owner account disabled (`account_disabled`: every call is rejected; accounts are disabled after a year without registering events, requesting certifications or buying credits, and support reactivates them). Data and chains are untouched. | `application/json`: `Error` | | `404` | Resource not found on this channel (`not_found`). | `application/json`: `Error` | | `429` | Rate limit exceeded (`rate_limited`) or, with a test key, a test-mode daily limit reached (`test_quota_exceeded`); includes `Retry-After`. | `application/json`: `Error` | ## Webhooks Notices the platform POSTs to the endpoints you configure in the console. They all share the envelope `WebhookEnvelope`. ### `certification.completed` — A requested certification finished successfully. The certification is `completed`: the reports, the evidence ZIP and the verification code are ready. `data.verification_code` is present; fetch `GET /certifications/{certification_id}` for fresh download URLs. `mode` is the certification's mode. `data`: `WebhookCertificationData` ### `certification.failed` — A requested certification failed (includes an incident id). The certification could not be issued (`status: failed`): any issuance charge is refunded and `data.incident_id` identifies the incident for support. The same range can be requested again. `mode` is the certification's mode. `data`: `WebhookCertificationData` ### `balance.low` — Balance crossed a user-defined threshold downwards. Sent once per downward crossing of each alert threshold set in the console; the threshold re-arms when a purchase or an adjustment takes the balance back above it. Always `live`. `data`: `WebhookBalanceLowData` ### `balance.exhausted` — First unpaid event registered (balance reached zero). The balance ran out and an event was registered as `unpaid` (it is still sealed and chained). From now on certifications are rejected with `402 account_not_operational` until a purchase settles the unpaid events. Sent once per exhaustion. Always `live`. `data`: `WebhookBalanceExhaustedData` ### `events.unpaid_limit_reached` — Unpaid-events limit reached; registrations are now being rejected. The account reached its unpaid-events limit: from now on `POST /events` answers `402 unpaid_limit_exceeded` until a purchase settles the debt. Always `live`. `data`: `WebhookUnpaidLimitData` ### `consumption.limit_reached` — A user-defined consumption limit was reached. A consumption limit set in the console (per channel or per key, daily or monthly in UTC) reached its maximum. With action `block` further registrations answer `422 limit_blocked` until the next period; with `warn` they keep going through. Sent once per limit and period. Test events do not count. Always `live`. `data`: `WebhookConsumptionLimitData` ### `recharge.auto_completed` — An automatic top-up was charged and its credits added. The balance dropped below the automatic top-up threshold set in the console and the chosen pack was bought with the default payment method at Safe Creative; its credits are already in the balance (after settling any unpaid events). Safe Creative sends the invoice. Always `live`. `data`: `WebhookAutoRechargeData` ### `recharge.auto_failed` — An automatic top-up failed; automatic top-up has been turned off. The automatic top-up could not be made (`data.reason`) and has been turned off, keeping its settings: it can be turned back on in the console. If the purchase was created but not charged, `data.payment_url` lets the owner pay it manually. Always `live`. `data`: `WebhookAutoRechargeData` ### `webhook.test` — Test delivery requested from the console. Only sent when the account owner asks for a test delivery of an endpoint from the console: a single attempt, no retries, and it never counts towards disabling the endpoint. Always `live`. `data`: `WebhookTestData` ## Schemas ### `NewEventContent` Content modality — JSON token generated by the client's process. | Field | Type | Description | |---|---|---| | `client_ref` (required) | `string` (maxLength 256) | Unique event id in the emitting system (idempotency key per channel). | | `payload` (required) | `object` | The declared content (JSON). Max body size 256 KB. | | `occurred_at` | `string (date-time)` | When the emitter declares the fact happened. Part of the declaration — Safe Creative proves the reception time, not this value. RFC 3339 date-time with an offset (`Z` or `±hh:mm`); normalized to UTC and truncated to microseconds (see [Formats](#formats)). | ### `NewEventHashOnly` Hash-only modality — the content never leaves the client's perimeter. | Field | Type | Description | |---|---|---| | `client_ref` (required) | `string` (maxLength 256) | Unique event id in the emitting system (idempotency key per channel). | | `hash` (required) | `string` | Hex-encoded digest of the client-held content. | | `hash_alg` (required) | `string: sha-256 · sha-512` | Algorithm used to compute `hash`. | | `occurred_at` | `string (date-time)` | When the emitter declares the fact happened. RFC 3339 date-time with an offset (`Z` or `±hh:mm`); normalized to UTC and truncated to microseconds (see [Formats](#formats)). | ### `NewEventFile` Content modality — file attachment. Max 10 MB. | Field | Type | Description | |---|---|---| | `client_ref` (required) | `string` (maxLength 256) | Unique event id in the emitting system (idempotency key per channel). | | `file` (required) | `string (binary)` | The file to declare, up to 10 MB. Its name is kept and returned when the content is downloaded. | | `occurred_at` | `string (date-time)` | When the emitter declares the fact happened. RFC 3339 date-time with an offset (`Z` or `±hh:mm`); normalized to UTC and truncated to microseconds (see [Formats](#formats)). | ### `Event` | Field | Type | Description | |---|---|---| | `event_id` (required) | `string` | Event id (`evt_…`). | | `channel_id` (required) | `string` | Channel id (`ch_…`). | | `client_ref` (required) | `string` | The `client_ref` declared by the emitter. | | `sequence` (required) | `integer` | Strictly increasing, gapless, per channel (per chain in test mode). | | `received_at` (required) | `string (date-time)` | Service reception time — the time the evidence attests. UTC, microseconds (see [Formats](#formats)). | | `occurred_at` | `string \| null (date-time)` | Declared by the emitter; not attested by Safe Creative. Returned in UTC, microseconds (see [Formats](#formats)). | | `hash` (required) | `string` | Digest of the content (or the client-declared digest in hash-only modality). | | `hash_alg` | `string` | Algorithm of `hash` (`sha-256` or `sha-512`). | | `modality` (required) | `string: content · hash_only` | content = Safe Creative holds the declared JSON or file, downloadable with `GET /events/{event_id}/content`; hash_only = only the digest was declared. | | `chain_hash` (required) | `string` | Chained digest binding this event to the previous one on the channel. | | `status` (required) | `string: registered · unpaid · test` | unpaid = registered & sealed but pending payment; blocks certifications account-wide. | | `timestamp` | `EventTimestamp` | Qualified-timestamp coverage of this event. | | `config_version` (required) | `integer` | Channel configuration version in force at reception. | | `mode` (required) | `string: live · test` | `live` or `test`: the mode of the key that declared it, each with its own chain. | ### `EventTimestamp` Qualified-timestamp coverage of an event (`Event.timestamp`). | Field | Type | Description | |---|---|---| | `type` (required) | `string: individual · batch · pending · simulated` | batch = proven by inclusion in a sealed chain segment of the channel (the sealing policy in use); pending = within the sealing window; simulated = test mode. individual = own qualified timestamp: **reserved**, not produced today (every live event is proven by its channel's sealed segment); kept so that individual sealing can be offered without breaking the contract. Clients should accept it. | | `reference` | `string \| null` | Timestamp or sealed-segment reference once consolidated (`seg_…` for a sealed segment). | | `sealed_at` | `string \| null (date-time)` | When the covering timestamp was obtained. UTC, microseconds (see [Formats](#formats)). | ### `Channel` | Field | Type | Description | |---|---|---| | `channel_id` (required) | `string` | Channel id (`ch_…`). | | `name` (required) | `string` | | | `mode_note` (required) | `string` | Reminder that live and test chains are separate. | | `current_config` (required) | `ChannelConfig` | | | `config_versions` (required) | `ChannelConfig[]` | | ### `ChannelConfig` A sealed, immutable configuration version. Changes create new versions; none is rewritten. | Field | Type | Description | |---|---|---| | `version` (required) | `integer` | Configuration version number, from 1. | | `sealed_at` (required) | `string (date-time)` | When this configuration version was sealed (pre-commitment to the facts). UTC, microseconds (see [Formats](#formats)). | | `modality` (required) | `string: content · hash_only` | | | `name` | `string` | | | `description` | `string` | | | `declarant` (required) | `string` | Person or entity on whose behalf events are declared and certifications are requested. Presented in reports as a declaration of the channel owner — not verified by Safe Creative. | | `supplementary_info` | `string \| null` | Optional client text explaining what the declared data means. | ### `NewCertification` Range by dates or by sequence numbers (exactly one pair). | Field | Type | Description | |---|---|---| | `from` | `string (date-time)` | Start of the reception range, inclusive. RFC 3339 date-time with an offset (`Z` or `±hh:mm`); normalized to UTC and truncated to microseconds (see [Formats](#formats)). | | `to` | `string (date-time)` | End of the reception range, exclusive; a future value is cut at the moment of the request. RFC 3339 date-time with an offset (`Z` or `±hh:mm`); normalized to UTC and truncated to microseconds (see [Formats](#formats)). | | `seq_from` | `integer` (minimum 1) | First sequence number, inclusive. | | `seq_to` | `integer` (minimum 1) | Last sequence number, inclusive. | ### `Certification` | Field | Type | Description | |---|---|---| | `certification_id` (required) | `string` | Certification id (`CERT-…`, or `CERT-TEST-…` in test mode). | | `channel_id` (required) | `string` | Channel id (`ch_…`). | | `status` (required) | `string: processing · completed · failed` | `processing` while it is being issued; `completed` when the reports, the evidence archive and the verification code are ready; `failed` if it could not be issued (any charge is refunded and the range can be requested again). | | `progress` | `number \| null` | Approximate progress (0–1) while processing. | | `requested_at` (required) | `string (date-time)` | When the request was accepted. UTC, microseconds (see [Formats](#formats)). | | `completed_at` | `string \| null (date-time)` | When the certification was issued (the issue date printed on the reports) or failed. UTC (see [Formats](#formats)). | | `range` (required) | `CertificationRange` | The range as requested. It is resolved and frozen on acceptance into sequence numbers of the chain. | | `event_count` | `integer \| null` | Number of events in the range, fixed on acceptance. | | `config_versions_in_range` | `integer[]` | Channel configuration versions in force for the events of the range, fixed on acceptance. | | `report_url` | `string \| null` | Download of the signed PDF report in the language of the request (`Accept-Language`: Spanish or English, English by default); expiring URL. `reports` lists both languages. | | `reports` | `CertificationReport[]` | The signed PDF reports: one in Spanish and one in English, signed separately, with the same verification code and the same evidence archive. Both are equally authentic and each can be verified on its own. Earlier certifications may have a single bilingual report (`languages: [es, en]`). Empty while processing. | | `evidence_url` | `string \| null` | Evidence ZIP download (expiring URL). | | `verification_code` | `string \| null` | Code for the public verification page. | | `mode` (required) | `string: live · test` | `live` or `test`: the mode of the key that requested it. | | `content_parts` | `ContentPart[]` | Content modality: the declared content, in ZIP parts of up to 1 GB that are listed with their SHA-256 in the evidence manifest and in the reports (expiring URLs, like `report_url`). Empty for hash-only channels and while processing. | ### `CertificationReport` One signed PDF report of a certification (`Certification.reports`). | Field | Type | Description | |---|---|---| | `languages` (required) | `string[]` | Language of the report, `[es]` or `[en]`; both in the bilingual report of earlier certifications. | | `url` (required) | `string` | Download URL (expiring, like `report_url`). | | `sha256` (required) | `string` | SHA-256 of the signed PDF, lowercase hex; the public verification page recognizes it. | ### `CertificationRange` Range of a certification (`Certification.range`), as requested: exactly one pair is set, the reception dates (`from`, `to`) or the sequence numbers (`seq_from`, `seq_to`); the other pair is null. | Field | Type | Description | |---|---|---| | `from` | `string \| null (date-time)` | Start of the reception range, inclusive. UTC, microseconds (see [Formats](#formats)). | | `to` | `string \| null (date-time)` | End of the reception range, exclusive; a future `to` is cut at the moment of the request. UTC, microseconds. | | `seq_from` | `integer \| null` | First sequence number, inclusive. | | `seq_to` | `integer \| null` | Last sequence number, inclusive. | ### `ContentPart` One ZIP part of the declared content of a certification (`Certification.content_parts`). | Field | Type | Description | |---|---|---| | `name` (required) | `string` | File name, `content-part-NNN.zip`. | | `url` (required) | `string` | Download URL (expiring, like `report_url`). | | `sha256` (required) | `string` | SHA-256 of the part, lowercase hex; also listed in the evidence manifest and in the reports. | | `bytes` (required) | `integer` | Size of the part in bytes. | ### `AccountBalance` Balance in credits. One live event consumes one credit; credits are bought in packs in the console, priced per region and currency. The balance itself has no currency. | Field | Type | Description | |---|---|---| | `balance` (required) | `integer` | Available credits. Negative only if a purchase is reverted after its credits were spent. | | `operational` (required) | `boolean` | false while the account has unpaid events or a negative balance ("cuenta sin saldo operativo"). | | `unpaid_events` (required) | `integer` | Live events registered without credits and not yet settled (one credit each). | ### `WebhookEnvelope` Common webhook body, sent as `POST` with `Content-Type: application/json`. Headers: `X-SC-Webhook-Id` (the envelope `id`, stable across retries: use it to de-duplicate), `X-SC-Timestamp` (Unix seconds) and `X-SC-Signature: v1=`, where `` is HMAC-SHA256 with the endpoint secret over `.`. Verify the signature and reject old timestamps (e.g. more than 5 minutes) before processing. Any 2xx acknowledges; otherwise retries with exponential backoff (1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h). An endpoint whose deliveries keep failing is disabled and the account is notified by email. `webhook.test` is only sent when the account owner asks for a test delivery from the console: a single attempt, no retries, and it never counts towards disabling the endpoint. The model of `data` depends on `type`. New fields may be added to `data` over time: ignore the ones you do not know. | `type` | `data` | Sent when | |---|---|---| | `certification.completed` | [`WebhookCertificationData`](#schema-WebhookCertificationData) | A certification was issued (`verification_code` present). | | `certification.failed` | [`WebhookCertificationData`](#schema-WebhookCertificationData) | A certification could not be issued (`incident_id` present). | | `balance.low` | [`WebhookBalanceLowData`](#schema-WebhookBalanceLowData) | The balance crossed an alert threshold downwards. | | `balance.exhausted` | [`WebhookBalanceExhaustedData`](#schema-WebhookBalanceExhaustedData) | The first event was registered as unpaid. | | `events.unpaid_limit_reached` | [`WebhookUnpaidLimitData`](#schema-WebhookUnpaidLimitData) | The unpaid-events limit was reached: registrations are rejected. | | `consumption.limit_reached` | [`WebhookConsumptionLimitData`](#schema-WebhookConsumptionLimitData) | A consumption limit reached its maximum for the period. | | `recharge.auto_completed` | [`WebhookAutoRechargeData`](#schema-WebhookAutoRechargeData) | An automatic top-up was charged and its credits added. | | `recharge.auto_failed` | [`WebhookAutoRechargeData`](#schema-WebhookAutoRechargeData) | An automatic top-up failed; automatic top-up was turned off. | | `webhook.test` | [`WebhookTestData`](#schema-WebhookTestData) | The account owner asked for a test delivery. | Example (`balance.low`): ```json {"id": "ntc_…", "type": "balance.low", "created_at": "2026-09-28T07:00:16.526317Z", "mode": "live", "data": {"threshold_id": "thr_…", "threshold": 1000, "balance": 999}} ``` | Field | Type | Description | |---|---|---| | `id` (required) | `string` | Notice id (`ntc_…`; `tst_…` for test deliveries), the same on every retry. Also in `X-SC-Webhook-Id`. | | `type` (required) | `string: certification.completed · certification.failed · balance.low · balance.exhausted · events.unpaid_limit_reached · consumption.limit_reached · recharge.auto_completed · recharge.auto_failed · webhook.test` | | | `created_at` (required) | `string (date-time)` | When the notice was generated. UTC, microseconds (see [Formats](#formats)). | | `mode` (required) | `string: live · test` | Mode of the fact notified. Account and balance notices are always `live`; certification notices carry the mode of the certification. | | `data` (required) | `WebhookCertificationData \| WebhookBalanceLowData \| WebhookBalanceExhaustedData \| WebhookUnpaidLimitData \| WebhookConsumptionLimitData \| WebhookAutoRechargeData \| WebhookTestData` | Payload of the notice. Its model depends on `type` (see the table above). | ### `WebhookCertificationData` `data` of `certification.completed` and `certification.failed`. | Field | Type | Description | |---|---|---| | `certification_id` (required) | `string` | Certification id (`CERT-2026-0000001`, `CERT-TEST-2026-0000001` in test). Use it with `GET /certifications/{certification_id}`. | | `channel_id` (required) | `string` | | | `status` (required) | `string: completed · failed` | | | `event_count` (required) | `integer` | Events in the certified range. | | `verification_code` | `string` | Public verification code (`SC-2026-XXXX-XXXX`, `SC-TEST-…` in test). Only in `certification.completed`. | | `incident_id` | `string` | Incident reference for support. Only in `certification.failed`. | ### `WebhookBalanceLowData` `data` of `balance.low`. | Field | Type | Description | |---|---|---| | `threshold_id` (required) | `string` | Id of the alert threshold that was crossed (`thr_…`). | | `threshold` (required) | `integer` | The threshold, in credits. | | `balance` (required) | `integer` | Balance in credits right after the crossing. | ### `WebhookBalanceExhaustedData` `data` of `balance.exhausted`. | Field | Type | Description | |---|---|---| | `channel_id` (required) | `string` | Channel of the first event registered as `unpaid`. | ### `WebhookUnpaidLimitData` `data` of `events.unpaid_limit_reached`. | Field | Type | Description | |---|---|---| | `unpaid_limit` (required) | `integer` | The account's unpaid-events limit. | | `unpaid_events` (required) | `integer` | Unpaid events at this moment (equal to the limit). | | `unpaid_credits` (required) | `integer` | Credits owed for them (one per event). | ### `WebhookConsumptionLimitData` `data` of `consumption.limit_reached`. | Field | Type | Description | |---|---|---| | `limit_id` (required) | `string` | Id of the consumption limit (`lim_…`). | | `scope` (required) | `string: channel · key` | Whether the limit applies to a whole channel or to one API key. | | `channel_id` (required) | `string` | Channel of the event that reached the limit. | | `key_id` (required) | `string \| null` | API key id (`key_…`) when `scope` is `key`; `null` for channel limits. | | `period` (required) | `string: day · month` | | | `period_key` (required) | `string` | The period that reached the limit, UTC calendar day `YYYY-MM-DD` or month `YYYY-MM`. | | `max_events` (required) | `integer` | Events allowed per period. | | `action` (required) | `string: warn · block` | `warn`: registrations keep going through. `block`: `422 limit_blocked` until the next period. | ### `WebhookAutoRechargeData` `data` of `recharge.auto_completed` and `recharge.auto_failed`. | Field | Type | Description | |---|---|---| | `recharge_id` (required) | `string \| null` | The purchase (its Safe Creative sale code). `null` when it failed before any purchase was created. | | `pack_id` (required) | `string` | The pack configured for automatic top-up. | | `credits` (required) | `integer \| null` | Credits of the pack bought. `null` when it failed before any purchase was created. | | `amount` (required) | `string \| null` | Net price of the pack, before taxes, as a decimal string. `null` when it failed before any purchase was created. | | `currency` (required) | `string \| null: EUR · GBP · USD · null` | Currency of `amount`, that of the owner's pricing region. | | `threshold` (required) | `integer` | The automatic top-up threshold, in credits. | | `balance` | `integer` | `recharge.auto_completed` only: balance in credits after adding the pack. | | `reason` | `string: payment_failed · sale_creation_failed · payment_method_missing · pack_unavailable · pricing_country_required · daily_limit · account_suspended · custom_pack_inactive` | `recharge.auto_failed` only: `payment_failed` (the charge was declined), `sale_creation_failed` (Safe Creative could not create the purchase, e.g. an expired payment method), `payment_method_missing` (no valid default payment method), `pack_unavailable` (the pack is no longer on sale), `pricing_country_required` (no billing country), `daily_limit` (daily maximum of automatic top-ups reached), `account_suspended` or `custom_pack_inactive` (the custom pack it bought was deactivated; it is turned off at that moment, without any purchase attempt). | | `payment_url` | `string \| null` | `recharge.auto_failed` only: page to pay the purchase manually when it was created but not charged. | | `auto_recharge_disabled` | `boolean` | `recharge.auto_failed` only: always `true`; automatic top-up was turned off and keeps its settings. | ### `WebhookTestData` `data` of `webhook.test`. | Field | Type | Description | |---|---|---| | `endpoint_id` (required) | `string` | The endpoint (`whk_…`) the test was requested for. | | `message` (required) | `string` | | ### `Error` | Field | Type | Description | |---|---|---| | `code` (required) | `string: validation_error · invalid_key · revoked_key · account_not_operational · unpaid_limit_exceeded · insufficient_balance · not_found · client_ref_conflict · payload_too_large · limit_blocked · rate_limited · test_quota_exceeded · internal · temporarily_unavailable · account_suspended · channel_suspended · account_disabled` | Stable, machine-readable error code: branch on it, never on `message`. `doc_url` links to its entry in the error catalog. - `validation_error`: invalid body, parameters or range, or a body that does not match the channel's modality. Fix the request: repeating it as is fails again. - `invalid_key`: the API key is missing, malformed or unknown. - `revoked_key`: the API key was revoked in the console. - `account_not_operational`: the owner account has unpaid events or a negative balance, so certifications are rejected until a purchase settles them. - `unpaid_limit_exceeded`: the owner account reached its unpaid-events limit: new events are rejected until a purchase settles the debt. - `insufficient_balance`: not enough credits for the certification issuance fee, when one is set. - `not_found`: the resource does not exist on this channel, or the event has no content to download (hash-only modality). - `client_ref_conflict`: the `client_ref` was already used on this channel with different content; the original event is kept. It points to a bug in the emitter. - `payload_too_large`: the body is over the modality limit (256 KB of JSON, 10 MB per file). - `limit_blocked`: a consumption limit set to block in the console was reached: registrations are rejected until its next period (UTC day or month). - `rate_limited`: too many requests with this key: wait `Retry-After` seconds and lower the sustained rate. - `test_quota_exceeded`: with a test key, a daily test-mode limit (events, declared content or certifications) was reached: wait `Retry-After`, until the next UTC day. - `internal`: unexpected server error (HTTP 5xx), with `incident_id`. Retry with exponential backoff (with the same `client_ref` when registering events) and quote `incident_id` to support if it persists. - `temporarily_unavailable`: transient storage failure; nothing was acknowledged. Retry after `Retry-After` (with the same `client_ref` when registering events). - `account_suspended`: Safe Creative suspended the owner account: event registration and certifications are rejected, queries keep working. Contact support. - `channel_suspended`: Safe Creative suspended the channel: every call with its keys is rejected, queries included. Contact support. - `account_disabled`: the owner account is disabled (after a year without operations, or by Safe Creative): every call is rejected until support reactivates it. | | `message` (required) | `string` | Human-readable; EN by default, ES via Accept-Language. | | `doc_url` | `string \| null` | Link to this code's entry in the error catalog, with what it means and what to do. | | `incident_id` | `string \| null` | Present on 5xx responses. |