Chapters 2 and 3 defined what a DAC record looks like. This chapter is about how those records move between systems. A plant's edge collector has to deliver capture data every few minutes. A storage operator needs to report injections. A verifier wants to pull a year of history. A registry needs a verified quantity it can turn into credits. All of them need the same few things from an interface: a way to find it, a way to prove who is calling, a predictable set of resources, and a clear answer when something goes wrong.
The WIA standard describes this interface in two places that were never fully merged. PHASE-4-INTEGRATION §2 contains the concrete API: an OAuth token endpoint, seven REST operations, a GraphQL schema and a WebSocket channel. PHASE-1-DATA-FORMAT and PHASE-2-API-INTERFACE, which share the same generic text, contain the discovery, conformance-tier, time and token-binding rules. The shipped api/openapi.yaml joins neither of them; it is a seven-line stub with a single /resource path. This chapter puts the pieces together in the order a client meets them: discovery, authentication, REST, GraphQL, WebSocket, the OpenAPI document that should describe all of it, and finally the time and identity rules that keep the records trustworthy.
Every hostname in this chapter is an example.com address. The specification writes its base URLs as api.wia.org, which is not a published service. All capture figures use the fictional facility dac-example-reykjanes-01 from Chapter 2 and are illustrative.
Client (edge collector, verifier, registry)
|
| 1. GET /.well-known/wia/direct-air-capture (RFC 8615)
| -> operation groups, OpenAPI URL, signing key, tier
| 2. verify discovery signature (Sigstore key)
| 3. POST /auth/token grant_type=client_credentials
| -> access_token (Bearer), expires_in, scope
| 4. REST / GraphQL / WebSocket calls with the token
| GET /facilities ... read
| POST /facilities/{id}/capture write
| wss facility:{id}:capture subscribe
v
DAC data service --> records validated against PHASE-2-DATA schemas
A client should not have hard-coded knowledge of where a DAC data service keeps its token endpoint or its OpenAPI document. RFC 8615 defines the /.well-known/ path prefix for exactly this purpose: a fixed location on any host where metadata about that host can be found. The standard registers its own location under that prefix, /.well-known/wia/direct-air-capture. Note that the slug direct-air-capture is used here, not the document number WIA-ENE-050 that appears in the specification body. Chapter 1 explained the two identifiers; for anything a machine resolves, the slug is the one to use.
The specification requires three items in the discovery document: the operation groups the service supports, the URL of its OpenAPI document, and the key that signs its manifest. It also requires that the discovery response itself be signed with the same Sigstore key as the manifest. It does not give a JSON layout. The following layout is this book's proposal. It covers the required items and adds the token endpoint and the conformance tier, because a client needs both before it can make its first call.
GET /.well-known/wia/direct-air-capture HTTP/1.1
Host: dac-data.example.com
Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: max-age=3600
{
"standardSlug": "direct-air-capture",
"specVersion": "1.0.0",
"operationGroups": ["facility", "capture", "storage", "verification", "credits"],
"openapi": "https://dac-data.example.com/openapi.json",
"tokenEndpoint": "https://dac-data.example.com/auth/token",
"graphql": "https://dac-data.example.com/graphql",
"websocket": "wss://dac-data.example.com/ws",
"conformanceTier": "Surface",
"manifest": "https://dac-data.example.com/manifest.json",
"signingKey": {
"type": "sigstore-keyless",
"identity": "release@dac-data.example.com",
"issuer": "https://issuer.example.com"
}
}
The operationGroups values are this book's suggestion; they correspond to the resource families in section 4.3. The operation-group idea is useful because not every service implements everything. A storage operator may expose only storage. A read-only public dashboard may expose only facility and capture. A client that reads the list can fail early and clearly instead of calling an endpoint that returns 404.
A signed discovery document protects against one specific attack: a compromised or misconfigured proxy that points clients at a different token endpoint or OpenAPI document. The specification does not say how the signature is carried. Two practical options are a detached signature published next to the document (for example …/direct-air-capture.sig) or a DSSE envelope that wraps the JSON. Either way, a client should verify the signature against the identity recorded in the service's signed manifest before it trusts any URL in the document. It should also cache the verified document for no longer than the Cache-Control lifetime, and it should treat a signature failure as a hard error, not as a warning.
The standard defines three conformance tiers: Surface (self-attested), Verified (annual third-party audit) and Anchored (continuous evidence package per Annex G). Chapter 8 covers what each tier requires. For the API, the relevant rule is that a service declares its tier in its OpenAPI document using the extension field x-wia-conformance-tier. OpenAPI allows extension fields that begin with x- at most levels of the document; the natural place for this one is the info object, as section 4.6 shows. If the discovery document also carries the tier, as proposed above, both values must agree. A client that finds a disagreement should believe the lower of the two, because a claim of a higher tier needs evidence and a claim of a lower one does not.
Almost every caller of a DAC data service is a machine: an edge collector at the plant, a storage operator's system, a verifier's analysis pipeline, a registry. PHASE-4 §2.1.1 therefore specifies the OAuth 2.0 client credentials grant. There is no end user and no browser redirect; the client authenticates as itself and receives an access token.
The specification shows the token request with a JSON body. That is a real interoperability problem. The OAuth 2.0 framework (RFC 6749) defines the token request as application/x-www-form-urlencoded, and standard OAuth client libraries and authorization servers expect that encoding. RFC 6749 also recommends HTTP Basic authentication for the client secret rather than putting it in the body. The request below follows RFC 6749; a server can accept the JSON form as well for compatibility, but clients should send the standard form.
POST /auth/token HTTP/1.1
Host: dac-data.example.com
Authorization: Basic ZWRnZS1yZXlramFuZXMtMDE6c2VjcmV0
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&scope=read%3Afacility%20write%3Acapture
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
{
"access_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "read:facility write:capture"
}
The Basic credential in the example is a placeholder. The token is then sent on every call as Authorization: Bearer …, as defined in RFC 6750. The expires_in value of 3600 seconds comes from the specification's example; it is a reasonable default, not a requirement. A client should request a new token shortly before expiry instead of waiting for a 401 response, because a failed write in the middle of a batch is harder to recover from than a slightly early refresh.
The specification names only two scopes, read:facility and write:capture. A complete service needs more, because writing a storage event or issuing credits is a very different permission from submitting sensor data. The table below keeps the two specified scopes and adds the others this book recommends, using the same action:resource pattern.
| Scope | Source | Grants | Typical holder |
|---|---|---|---|
read:facility | PHASE-4 §2.1.1 | Facility list and details; read capture data | Dashboards, verifiers, registries |
write:capture | PHASE-4 §2.1.1 | Submit capture records for facilities bound to the client | Plant edge collector |
write:storage | Book recommendation | Submit storage events | Storage operator, plant operator |
read:verification | Book recommendation | Read verification reports | Registries, buyers, auditors |
write:verification | Book recommendation | Submit verification reports | Accredited verifier only |
issue:credits | Book recommendation | Request credit issuance against a verified report | Registry integration, never the plant |
Two design rules sit behind this table. First, a scope is never enough on its own. A client holding write:capture must be bound, on the server side, to the facility IDs it may write. Otherwise any plant could submit data for any other plant. Second, the scopes are split so that no single client can both write the evidence and create the credit. The system that produces capture data should not hold the scope that turns that data into money. This is the API form of the separation of duties that MRV depends on (Chapter 6).
PHASE-4 §9.1 lists four mechanisms: OAuth 2.0, API keys for machine-to-machine traffic, mTLS for high-security connections, and JWTs for stateless authentication. They are not alternatives at the same level. JWT is a token format, which an OAuth server may or may not use. API keys are long-lived shared secrets; they are simple, but they cannot carry scopes or expire on their own, and a leaked key stays valid until someone notices. For a read-only public endpoint an API key is acceptable. For anything that writes evidence, OAuth tokens are the better choice.
The specification's time-and-identity section adds that time-bound tokens are "verified against the TLS session's exporter value (RFC 8446 §7.5) for token-binding". The intent is sound: a token that is stolen from a log file should be useless to an attacker. However, the text does not name a concrete protocol that performs this binding. Two IETF mechanisms do. RFC 8705 defines mutual-TLS client authentication and certificate-bound access tokens: the token records a hash of the client's certificate, and the resource server rejects the token on any connection that does not present that certificate. RFC 9449 (DPoP) binds the token to a key held by the client and proves possession with a signed header on each request. For plant edge devices that already hold a device certificate, RFC 8705 is usually the simpler of the two. This book recommends that implementations state which mechanism they use in their profile manifest instead of citing the exporter sentence alone.
One citation needs correcting. The specification and README describe RFC 9700 as "OAuth 2.1". RFC 9700 is the OAuth 2.0 Security Best Current Practice. OAuth 2.1 is a separate IETF document that consolidates OAuth 2.0 and its security guidance. For a machine-to-machine DAC service the practical effect is the same, since both point to the same set of practices: short-lived tokens, sender-constrained tokens where possible, and no secrets in URLs.
PHASE-4 §9.2 lists default rate limits of 100 requests per hour for the public API, 1,000 per hour for authenticated clients and 10,000 per hour for partners, with WebSocket listed as "unlimited (fair use)". These are example configuration values, not requirements, and a service should set its own. Arithmetic helps here. An edge collector that submits one record per minute makes 60 requests per hour, well within the authenticated example. A verifier that pulls a year of hourly data one page at a time, at 50 records per page, needs about 175 requests. The limits matter more for the second kind of client. Whatever limits a service chooses, it should reject excess calls with HTTP 429 and a Retry-After header (RFC 9110), and it should publish the limits in its OpenAPI document so that clients can plan.
The REST interface in PHASE-4 §2.1.2 has seven operations. Table 4-2 lists them with the scope each should require and the PHASE-2-DATA schema that defines its payload. The scope column combines the specified scopes with the recommendations in Table 4-1.
| Method and path | Purpose | Scope | Request | Response data |
|---|---|---|---|---|
GET /facilities | List facilities; filters country, status | read:facility | Query parameters | Array of facility summaries |
GET /facilities/{facilityId} | One facility | read:facility | Path parameter | Facility Information (PHASE-2-DATA §2.1) |
GET /facilities/{facilityId}/capture | Capture history; start, end, interval | read:facility | Query parameters | Array of capture records or aggregates |
POST /facilities/{facilityId}/capture | Submit a capture record | write:capture | Real-Time Capture record (§3.1) | recordId, status |
POST /storage/events | Submit a storage event | write:storage | Storage Event (§5.1) | eventId, status: pending-verification |
GET /verification/reports | List reports; facilityId, year | read:verification | Query parameters | Array of Verification Reports (§6.1) |
POST /credits/issue | Issue credits against a verified report | issue:credits | Issuance request | Carbon Credit summary (§7.1) |
Two gaps are visible straight away. There is no endpoint for submitting a verification report, and none for the sorbent records of PHASE-2-DATA §4. The first is a real omission, since otherwise reports can only enter the system through a side channel. This book recommends POST /verification/reports with the write:verification scope. The second is a deliberate choice for many operators: sorbent formulations can be commercially sensitive, and Chapter 7 discusses tiered access for them.
The list endpoint takes country and status filters. The country value is an ISO 3166-1 alpha-2 code, the same as location.country in the facility record, and status takes the values of the operational.status enumeration. A server should reject unknown values with a 400 error instead of returning an empty list. An empty list is a valid answer to a correct question, and a client cannot tell it apart from a typo.
The capture history endpoint takes start, end and interval. The specification uses interval=hourly but does not list the other values. A sensible set is raw, hourly and daily, matching the measurement cadence in Chapter 2 (1–5 minute sampling, hourly aggregates, daily summaries). The time window should be interpreted as half-open: start included, end excluded. The specification's example uses end=…T23:59:59Z, which works for whole seconds but silently drops any record time-stamped in the last second of the day when timestamps have fractional seconds. With a half-open window, a day is simply start=2025-06-01T00:00:00Z&end=2025-06-02T00:00:00Z.
GET /v1/facilities/dac-example-reykjanes-01/capture?start=2025-06-01T00:00:00Z&end=2025-06-01T03:00:00Z&interval=hourly HTTP/1.1
Host: dac-data.example.com
Authorization: Bearer eyJhbGciOi...
HTTP/1.1 200 OK
Content-Type: application/json
{
"success": true,
"data": [
{ "timestamp": "2025-06-01T00:00:00Z",
"capture": { "hourlyTotal": 0.12 },
"energy": { "hourlyTotal": 0.242, "specificEnergy": 2.02 } },
{ "timestamp": "2025-06-01T01:00:00Z",
"capture": { "hourlyTotal": 0.12 },
"energy": { "hourlyTotal": 0.241, "specificEnergy": 2.01 } },
{ "timestamp": "2025-06-01T02:00:00Z",
"capture": { "hourlyTotal": 0.11 },
"energy": { "hourlyTotal": 0.240, "specificEnergy": 2.18 } }
],
"metadata": { "timestamp": "2025-06-01T09:12:44Z", "version": "1.0.0", "requestId": "req-7f3c" },
"pagination": { "page": 1, "limit": 50, "total": 3 }
}
The figures are illustrative and consistent with the Chapter 2 facility: about 0.12 t per hour matches 2.9 t/day, and 0.242 MWh of energy in an hour divided by 0.12 t gives about 2.0 MWh/t. The aggregated rows use hourlyTotal fields that the PHASE-4 example introduces but the PHASE-2-DATA capture schema does not define. An aggregate is a different object from a raw capture record and deserves its own schema. Otherwise a validator that checks responses against the capture record schema will reject the aggregates, because they lack the required currentRate. Section 4.6 defines it as CaptureAggregate.
The envelope in PHASE-2-DATA §8.1 uses page-number pagination: page, limit and total. That works for facility lists, which are short and change slowly. It works poorly for capture history, which grows while a client reads it. If a new record arrives between page 3 and page 4, offset-based paging can repeat or skip a row. For time-series endpoints this book recommends keyset pagination: the server returns an opaque next cursor that encodes the last timestamp and record ID it returned, and the client passes that cursor back. The page-number fields can stay in the envelope for compatibility, but total should be optional on time-series endpoints, since counting a growing table on every request is expensive and the number is out of date as soon as it is sent.
An edge collector posts one capture record at a time to POST /facilities/{facilityId}/capture. The body is the Real-Time Capture record of Chapter 2. The server validates it against the schema, checks that the facilityId in the body matches the one in the path and that the client is allowed to write for it, and returns a server-assigned recordId. The specification's response uses "status": "accepted" without an HTTP status code. This book recommends 201 Created with a Location header when the record is stored synchronously, and 202 Accepted when it is only queued.
Networks at remote plant sites are not reliable, and an edge collector will sometimes not know whether its last POST arrived. If it retries blindly, the same measurement may be stored twice, and the plant appears to have captured more CO2 than it did. That is the worst possible failure for a removal ledger. There are two defences, and a service should use both.
facilityId, timestamp) identifies a capture record. The server should enforce it as a unique key and treat a second POST with the same pair and identical content as a no-op that returns the original recordId. A second POST with the same pair but different content is a conflict (409) and must be investigated, not overwritten.Idempotency-Key header with a unique value per logical operation. The server stores the key with the result and replays the stored result for any repeat. The header is described in an IETF HTTPAPI working-group Internet-Draft. It is not yet an RFC, but it is widely implemented in payment APIs, which face the same double-spend problem.POST /storage/events returns an eventId and the status pending-verification. That status is the most important word in the whole API. Submitting a storage event is a claim, not a fact. The event becomes evidence only when a verification report references it (Chapter 3). The correct HTTP status is therefore 202 Accepted. A client, and especially a dashboard, must never add pending events to a "tonnes stored" total.
POST /credits/issue takes a verificationReportId, an amount, a vintage and a registry, and returns a creditId and serial number. The server must refuse the request unless the referenced report has the conclusion verified or verified-with-conditions, and unless the amount requested, added to the credits already issued against that report, stays within the report's verifiedAmount. This check is the API-level defence against double issuance. It must run inside the same database transaction that records the new credit, otherwise two parallel requests can each pass the check. A duplicated issuance request that carries the same Idempotency-Key should return the first credit, not a second one.
Note also that this endpoint issues credits in the WIA system's own records. Real registries such as Puro.earth, Verra, Gold Standard and ACR have their own issuance processes, and the WIA serial number does not replace a registry serial. Chapter 6 covers that mapping.
Chapter 3 compared the specification's error object (success: false with error.code, message and details, such as INVALID_FACILITY_ID) with RFC 9457 Problem Details, which the README cites. It recommended RFC 9457 (application/problem+json) for all errors, with the WIA error code kept as an extension member. Under RFC 9457 the HTTP status code carries the primary meaning, so the mapping from situation to status must be consistent across every endpoint. The specification is silent on status codes, so Table 4-3 gives this book's recommendation.
| Situation | Status | Example error code |
|---|---|---|
| Body fails schema validation; unknown filter value | 400 | VALIDATION_FAILED, INVALID_FACILITY_ID |
| Missing, expired or invalid token | 401 | INVALID_TOKEN |
| Valid token, but scope or facility binding does not allow it | 403 | INSUFFICIENT_SCOPE |
| Facility, report or event does not exist | 404 | NOT_FOUND |
| Same natural key with different content; issuance exceeds verified amount | 409 | DUPLICATE_RECORD, ISSUANCE_EXCEEDS_VERIFIED |
| Well-formed but fails a consistency check (Chapter 2 §2.7) | 422 | INCONSISTENT_RECORD |
| Rate limit exceeded | 429 | RATE_LIMITED |
The codes other than INVALID_FACILITY_ID are this book's suggestions. The split between 400 and 422 is useful: 400 means the record cannot be read as a valid record, and 422 means it can be read but contradicts itself, for example when specificEnergy does not equal energy divided by capture within tolerance. An edge collector should never retry a 400 or 422 unchanged; it should quarantine the record and raise an alert. It can retry 429 and 5xx responses with backoff.
PHASE-4 §2.2 defines a GraphQL schema with the three root types that GraphQL provides. Query covers facilities, capture data, storage events, verification reports and credits. Mutation has submitCaptureData, submitStorageEvent and issueCredits. Subscription has captureDataUpdated and storageEventCreated. The object types (Facility, CaptureData and others) mirror the PHASE-2-DATA schemas, and the specification ends the listing with "# Additional types…". So the GraphQL schema is incomplete in the same way as the OpenAPI stub, and it has to be generated from the same JSON Schemas if the two interfaces are not to drift apart.
GraphQL earns its place where a client needs related data of different shapes in one round trip. A monitoring dashboard that shows a facility, its latest capture record and its last storage event would need three REST calls. In GraphQL it is one query:
query FacilityOverview($id: ID!) {
facility(facilityId: $id) {
facilityId
name
capacity { annualTonsCO2 }
realtimeData {
timestamp
capture { currentRate efficiency }
energy { currentConsumption specificEnergy }
}
}
storageEvents(facilityId: $id) {
eventId
amount
storageMethod
}
}
Some cautions apply. First, the authorization rules of section 4.2 must hold field by field, not only at the endpoint. A client with read:facility must not reach verification reports by nesting them inside a facility query. Second, a public GraphQL endpoint needs query depth and cost limits, since a single deeply nested query can be as expensive as thousands of REST calls, and the per-request rate limits of section 4.2.4 do not capture that. Third, the mutations need the same idempotency protection as the REST POSTs. GraphQL has no standard header for it, so an idempotencyKey argument on each mutation is the usual solution. Finally, the storageEvents query returns a plain list with no pagination argument, while facilities takes one. Every list that can grow should be paginated.
In practice, many services should treat REST as the primary interface for writes and GraphQL as a read-optimised view. Writes are where idempotency, audit logging and conformance evidence matter most, and they are easier to test and audit as a small set of fixed REST operations.
PHASE-4 §2.3 defines a WebSocket endpoint for live monitoring. The client connects, sends an authentication message containing its bearer token, then sends a subscribe message for a channel named facility:{facilityId}:capture. The server pushes capture_update messages containing the facility ID, a timestamp and the capture and energy values. FOUNDATION §5.2.2 sets an update frequency of 1–60 seconds, configurable, and a latency target of under 5 seconds from measurement to API. Like the rate limits, the latency figure is an example target, not a requirement that all deployments must meet.
Sending the token in the first message instead of the URL is a good choice: URLs end up in proxy and server logs, and a token in a query string leaks with them. The server should close the connection if no valid auth message arrives within a few seconds, and it should apply the same scope and facility binding as the REST API when a client subscribes.
The specification's client sample uses ws.on('open', …) and ws.on('message', …). That is the API of the Node.js ws library, not the browser WebSocket object, which uses onopen and addEventListener. The sample below works in both browsers and current Node.js versions. It also adds the two things the specification leaves out, reconnection and backfill.
// Illustrative client: live capture updates with reconnect and backfill
function watchCapture(facilityId, getToken, onRecord) {
let lastTs = null; // timestamp of the last record we processed
let delay = 1000; // reconnect backoff, ms
async function backfill() {
if (!lastTs) return;
const url = `https://dac-data.example.com/v1/facilities/${facilityId}/capture`
+ `?start=${encodeURIComponent(lastTs)}&interval=raw`;
const res = await fetch(url, { headers: { Authorization: `Bearer ${await getToken()}` } });
const body = await res.json();
for (const rec of body.data) if (rec.timestamp > lastTs) handle(rec);
}
function handle(rec) {
lastTs = rec.timestamp; // UTC ISO 8601 strings of equal precision sort correctly
onRecord(rec);
}
async function connect() {
const ws = new WebSocket('wss://dac-data.example.com/ws');
ws.addEventListener('open', async () => {
delay = 1000;
ws.send(JSON.stringify({ type: 'auth', token: await getToken() }));
ws.send(JSON.stringify({ type: 'subscribe', channel: `facility:${facilityId}:capture` }));
await backfill(); // close the gap left by the previous disconnect
});
ws.addEventListener('message', (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === 'capture_update' && (!lastTs || msg.timestamp > lastTs)) {
handle({ timestamp: msg.timestamp, facilityId: msg.facilityId, ...msg.data });
}
});
ws.addEventListener('close', () => {
setTimeout(connect, delay);
delay = Math.min(delay * 2, 60000);
});
}
connect();
}
The pattern behind this code is general. A WebSocket stream is a convenience for low latency, not a record of truth. Messages sent while the client is disconnected are lost. The REST history endpoint is the record of truth, so every reconnect is followed by a backfill query from the last timestamp the client processed, and duplicates are dropped by timestamp. The string comparison works only because all timestamps are UTC with the same precision; section 4.7 explains why that must be enforced. A more robust design has the server add a per-channel sequence number to each message, so that a client can detect a gap directly. This book recommends adding one, as an optional seq field.
For machine-to-machine telemetry between a plant and a storage site, Chapter 7 discusses MQTT as an alternative. WebSocket is best suited to dashboards and operator consoles.
An OpenAPI document is where all of the above becomes testable. Code generators produce clients from it, contract tests check the server against it, and the conformance tier is declared in it. The evidence package described in Annex G, covered in Chapter 8, must include it. The shipped api/openapi.yaml cannot serve any of these purposes. It declares OpenAPI 3.1.0, an example.com server and a single /resource path with a GET and a POST that have no schemas.
A complete document can be derived mechanically from the sources already covered. The procedure is as follows.
components/schemas. OpenAPI 3.1 is aligned with JSON Schema 2020-12, which the standard also uses, so the schemas can be used unchanged. That was not possible in OpenAPI 3.0, which used a modified subset.data left open, and compose each response from it with allOf. Define the RFC 9457 problem object recommended in Chapter 3 once as well, with media type application/problem+json, and reference it from every error response.POST /verification/reports.oauth2 scheme with the client-credentials flow and the scopes of Table 4-1. Each operation lists the scope it requires.x-wia-conformance-tier in info; optionally rate-limit information per operation.The excerpt below shows the shape of the result for one read and one write operation. It is abbreviated; the full component schemas are those of Chapters 2 and 3.
openapi: 3.1.0
info:
title: WIA Direct Air Capture Data API (example deployment)
version: 1.0.0
x-wia-conformance-tier: Surface
jsonSchemaDialect: https://json-schema.org/draft/2020-12/schema
servers:
- url: https://dac-data.example.com/v1
components:
securitySchemes:
oauth:
type: oauth2
flows:
clientCredentials:
tokenUrl: https://dac-data.example.com/auth/token
scopes:
read:facility: Read facilities and capture data
write:capture: Submit capture records
write:storage: Submit storage events
read:verification: Read verification reports
write:verification: Submit verification reports
issue:credits: Issue credits against a verified report
parameters:
FacilityId:
name: facilityId
in: path
required: true
schema: { type: string, pattern: "^dac-[a-z0-9-]+$" }
schemas:
CaptureRecord: { $ref: "./schemas/capture-record.json" } # PHASE-2-DATA 3.1
CaptureAggregate:
type: object
required: [timestamp, capture]
properties:
timestamp: { type: string, format: date-time }
capture:
type: object
properties: { hourlyTotal: { type: number, minimum: 0 } }
energy:
type: object
properties:
hourlyTotal: { type: number, minimum: 0 }
specificEnergy: { type: number, minimum: 0 }
Envelope:
type: object
required: [success]
properties:
success: { type: boolean }
metadata:
type: object
properties:
timestamp: { type: string, format: date-time }
version: { type: string }
requestId: { type: string }
paths:
/facilities/{facilityId}/capture:
parameters:
- $ref: "#/components/parameters/FacilityId"
get:
operationId: listCapture
security: [ { oauth: [read:facility] } ]
parameters:
- { name: start, in: query, required: true, schema: { type: string, format: date-time } }
- { name: end, in: query, schema: { type: string, format: date-time } } # optional; defaults to now
- { name: interval, in: query, schema: { type: string, enum: [raw, hourly, daily] } }
responses:
"200":
description: Capture records (raw) or aggregates (hourly, daily)
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/Envelope"
- properties:
data:
type: array
items:
oneOf:
- $ref: "#/components/schemas/CaptureRecord"
- $ref: "#/components/schemas/CaptureAggregate"
post:
operationId: submitCapture
security: [ { oauth: [write:capture] } ]
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/CaptureRecord" }
responses:
"201": { description: Stored; Location header points to the record }
"409": { description: Same facilityId and timestamp with different content }
"422": { description: Record fails a consistency check }
Three details deserve attention. The pattern for the facility ID is quoted, because YAML treats some characters in regular expressions specially. The oneOf between raw records and aggregates only works if the two schemas can never both match the same object. A raw record requires currentRate, but the aggregate schema does not forbid it, so an object that carries both sets of fields would match both and fail oneOf. A strict implementation should therefore forbid currentRate on aggregates, or use anyOf. Finally, the path is /facilities/… with the /v1 version in the server URL, matching the specification's base URL structure, so that a future /v2 can run in parallel during a deprecation window. Chapter 8 discusses how long that window should be, given that the specification states several different values.
Once this document exists, the rest of the tooling follows. A contract-test tool can replay the test vectors against the live service, a linter can check that every operation has a security requirement, and the same schemas can generate the GraphQL types of section 4.4.
The README shows a TypeScript quick start that installs @wia/direct-air-capture and calls client.submitRecord(record). It points to api/typescript/ for the SDK, but that directory is not shipped in the repository. The example below should therefore be read as a sketch of what such a client needs to do, not as documentation of an existing package. It shows three behaviours that matter more than the method names: token caching, an idempotency key per logical submission, and retrying only the errors that can be retried.
// Illustrative only: the @wia/direct-air-capture SDK is referenced but not shipped.
import { Client } from '@wia/direct-air-capture';
const client = new Client({
baseUrl: 'https://dac-data.example.com/v1',
tokenUrl: 'https://dac-data.example.com/auth/token',
clientId: process.env.DAC_CLIENT_ID!,
clientSecret: process.env.DAC_CLIENT_SECRET!,
scopes: ['write:capture'],
});
const record = {
timestamp: '2025-06-01T10:30:00Z',
facilityId: 'dac-example-reykjanes-01',
capture: { currentRate: 2.9, dailyTotal: 1.27 },
energy: { currentConsumption: 242, dailyTotal: 2.54, specificEnergy: 2.0 },
};
try {
// The client derives an idempotency key from facilityId + timestamp,
// refreshes the token before expiry, and retries 429/5xx with backoff.
const { recordId } = await client.submitRecord(record);
console.log('stored', recordId);
} catch (err) {
// 400/422: do not retry; keep the record for inspection and raise an alert.
quarantine(record, err);
}
The numbers are illustrative and consistent with each other. By 10:30 UTC, 10.5 hours into the day, a facility running at 2.9 t/day has captured about 1.27 t. At 242 kW, it has used about 2.54 MWh, which gives 2.0 MWh/t.
Every step in the chain from capture to credit depends on the order of events. A storage event must come after the capture that produced its CO2. A verification report covers a period with a start and an end. A credit's vintage is a year. If clocks disagree or timestamps are ambiguous, none of these relations can be checked.
The specification requires synchronised clocks at NTPv4 (RFC 5905) stratum 2 or better, and Annex E recommends PTP (IEEE 1588) for sites that need deterministic interlocks. For data exchange, three rules make timestamps safe to compare.
Z suffix, never local time and never a local offset. PHASE-2-DATA says "ISO 8601"; RFC 3339 is the profile of ISO 8601 that JSON Schema's date-time format uses, and it is the one to follow."…10:30:00Z" sorts after "…10:30:00.500Z" because the character Z comes after the character ..A capture record's timestamp is the time of measurement, assigned by the plant. The envelope's metadata.timestamp is the time of the response, assigned by the server. Neither is the time at which the server received and stored the record. That third time is essential for audits. It shows whether a month's data arrived continuously or was uploaded in one batch just before verification. This book recommends that the server store a receivedAt value with every record and expose it on read. It should also flag records whose measurement time is later than their receipt time by more than the clock tolerance, since such records come from a device with a wrong clock.
The same distinction appears at the level of the manifest. The CLI command direct-air-capture.sh manifest-template prints a manifest whose issuedAt is fixed at 2026-04-26T00:00:00Z. That is a placeholder in a template, not the current time. An implementer who publishes the template unchanged will sign a manifest that claims an issue date unrelated to the build it describes. Worse, a consumer that orders manifests by issuedAt to find the latest may pick the wrong one. Set issuedAt when the manifest is actually generated and signed, and never confuse it with the timestamps of the data records the implementation processes. Chapter 8 walks through the CLI in full.
Four kinds of identifiers cross the API, and each has one owner. The facilityId (pattern ^dac-[a-z0-9-]+$) is assigned at registration and never reused. The recordId and eventId are assigned by the server when it accepts a submission, and the client stores them as proof of delivery. The requestId in the envelope metadata identifies one HTTP exchange; clients should log it, because it is the fastest way to find a request in the server's audit log. The Idempotency-Key is assigned by the client and identifies one logical operation across retries. Mixing these roles, for example by using the requestId as an idempotency key, breaks retries. A retried request is a new exchange with a new request ID, but the same logical operation.
/.well-known/wia/direct-air-capture (RFC 8615) and must be signed. This chapter proposes a JSON layout; the tier is declared with x-wia-conformance-tier.Idempotency-Key. A storage event is pending-verification until a report references it. Credit issuance must be checked against the verified amount in one transaction.issuedAt are three different things.