Client libraries
Generate a client
The OpenAPI 3.1 document is at https://contents.team/api/v1/openapi.json (YAML at https://contents.team/openapi/v1.yaml) and needs no key. A few things to know before you generate:
- Download it into your repository and generate from that copy, so a client only changes when you choose. Changes are additive and listed in the changelog.
- Schemas are defined inline on each operation, so generated type names come from the path or operation id (for example
ClaimsPostRequestBodyorCreateClaimBody), not from a shared model list. - Every POST needs an
Idempotency-Keyheader. Generate a new one per logical request and send the same one when you retry it. See Requests and errors. - Authentication is a bearer token: an API key (
ctk_test_...orctk_live_...) or an OAuth access token. Build against a test key first; see Test mode.
Each example creates a claim and then lists its items, following the cursor.
TypeScript: openapi-typescript and openapi-fetch
openapi-typescript turns the document into types only; openapi-fetch is a small typed wrapper over fetch that checks paths, parameters and bodies against them.
npm install openapi-fetch
npm install --save-dev openapi-typescript typescript
npx openapi-typescript https://contents.team/api/v1/openapi.json -o ./src/contents-api.d.tsimport createClient from 'openapi-fetch';
import type { paths } from './contents-api';
const contents = createClient<paths>({
baseUrl: 'https://contents.team',
headers: { Authorization: `Bearer ${process.env.CONTENTS_API_KEY}` },
});
// Create a claim. Every POST needs an Idempotency-Key; reuse it on retries.
const { data: claim, error } = await contents.POST('/api/v1/claims', {
params: { header: { 'Idempotency-Key': crypto.randomUUID() } },
body: {
externalRef: 'JOB-1042',
claimNumber: 'HO-2026-118342',
insuredName: 'Dana Whitfield',
lossType: 'Water',
dateOfLoss: '2026-10-19',
},
});
if (error) throw new Error(`${error.error.code}: ${error.error.message}`);
console.log(claim.id); // clm_...
// List the claim's items, following the cursor.
let cursor: string | undefined;
do {
const { data: page, error: listError } = await contents.GET('/api/v1/claims/{claimId}/items', {
params: { path: { claimId: claim.id }, query: { limit: 100, cursor } },
});
if (listError) throw new Error(listError.error.message);
for (const item of page.data) {
console.log(item.name, item.quantity, item.valuation.rcvCents);
}
cursor = page.nextCursor ?? undefined;
} while (cursor);C# and .NET: Kiota
dotnet tool install --global Microsoft.OpenApi.Kiota
kiota generate -l CSharp -d https://contents.team/api/v1/openapi.json -c ContentsClient -n Contents.Api -o ./ContentsApi
dotnet add package Microsoft.Kiota.Bundleusing Contents.Api;
using Contents.Api.Api.V1.Claims;
using Microsoft.Kiota.Abstractions.Authentication;
using Microsoft.Kiota.Http.HttpClientLibrary;
var auth = new ApiKeyAuthenticationProvider(
"Bearer " + Environment.GetEnvironmentVariable("CONTENTS_API_KEY"),
"Authorization",
ApiKeyAuthenticationProvider.KeyLocation.Header);
var contents = new ContentsClient(new HttpClientRequestAdapter(auth));
// Create a claim. Every POST needs an Idempotency-Key; reuse it on retries.
// Nullable fields are generated as wrappers: set .String (or .Integer, .DateOnly).
var claim = await contents.Api.V1.Claims.PostAsClaimsPostResponseAsync(
new ClaimsPostRequestBody
{
ExternalRef = new() { String = "JOB-1042" },
ClaimNumber = new() { String = "HO-2026-118342" },
InsuredName = new() { String = "Dana Whitfield" },
LossType = new() { String = "Water" },
DateOfLoss = new() { DateOnly = new(2026, 10, 19) },
},
config => config.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString()));
Console.WriteLine(claim!.Id); // clm_...
// List the claim's items, following the cursor.
string? cursor = null;
do
{
var page = await contents.Api.V1.Claims[claim.Id].Items.GetAsItemsGetResponseAsync(config =>
{
config.QueryParameters.Limit = 100;
config.QueryParameters.Cursor = cursor;
});
foreach (var item in page!.Data!)
Console.WriteLine($"{item.Name} {item.Quantity} {item.Valuation!.RcvCents}");
cursor = page.NextCursor;
} while (cursor is not null);nswag openapi2csclient) also reads the document, and its response types work. Its request types do not: optional fields that accept null, such as externalRef, come out as empty classes with no value to set, so creating or editing a claim does not work. Use Kiota for .NET clients that write.Python: openapi-python-client
This generates an installable package (contents_team_api_client) with one module per operation and sync and asyncio functions in each.
pipx run openapi-python-client generate --url https://contents.team/api/v1/openapi.json
pip install ./contents-team-api-clientimport datetime, os, uuid
from contents_team_api_client import AuthenticatedClient
from contents_team_api_client.api.claims import create_claim
from contents_team_api_client.api.items import list_items
from contents_team_api_client.models import (
CreateClaimBody,
CreateClaimBodyLossTypeType0,
CreateClaimResponse201,
)
from contents_team_api_client.types import UNSET
client = AuthenticatedClient(
base_url="https://contents.team",
token=os.environ["CONTENTS_API_KEY"], # sent as "Authorization: Bearer ctk_..."
)
with client:
# Create a claim. Every POST needs an Idempotency-Key; reuse it on retries.
claim = create_claim.sync(
client=client,
idempotency_key=str(uuid.uuid4()),
body=CreateClaimBody(
external_ref="JOB-1042",
claim_number="HO-2026-118342",
insured_name="Dana Whitfield",
loss_type=CreateClaimBodyLossTypeType0.WATER,
date_of_loss=datetime.date(2026, 10, 19),
),
)
if not isinstance(claim, CreateClaimResponse201):
raise RuntimeError(f"create_claim failed: {claim.error.code if claim else 'no response'}")
print(claim.id) # clm_...
# List the claim's items, following the cursor.
cursor = UNSET
while True:
page = list_items.sync(claim.id, client=client, limit=100, cursor=cursor)
for item in page.data:
print(item.name, item.quantity, item.valuation.rcv_cents)
if not page.has_more:
break
cursor = page.next_cursorThese examples were checked on 9 November 2026 with openapi-typescript 7.13 with openapi-fetch 0.17, Kiota 1.38 with Microsoft.Kiota.Bundle 2.1.2, NSwag 14.7 and openapi-python-client 0.30.
Verify webhook signatures
Every delivery carries a Contents-Signature header of the form t=<unix seconds>,v1=<hex>. The full delivery format is in Webhooks. To check a delivery:
- Read the request body as raw bytes. Do not parse the JSON and serialize it again: key order and whitespace change, and the signature stops matching.
- Split the header on commas.
tis the signing time in Unix seconds. There can be more than onev1, and any one that matches passes. - Compute HMAC-SHA256 of
<t>.<raw body>, keyed with the whole secret string,whsec_prefix included, as UTF-8 bytes. The secret is not base64: do not decode it. - Hex-encode the result in lowercase and compare it with each
v1in constant time. - Reject the delivery if
tis more than 300 seconds (5 minutes) from your clock, in either direction. This stops a captured delivery being replayed.
Answer 400 when the check fails. Answer 2xx only after it passes.
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
/**
* Checks a Contents-Signature header ("t=<unix seconds>,v1=<hex>") against the raw body.
* rawBody must be the exact bytes received, before any JSON parsing.
* secret is the whole whsec_... string, used as-is (not base64-decoded).
*/
export function verifyContentsSignature(
rawBody: string | Buffer,
header: string | null | undefined,
secret: string,
nowSeconds = Math.floor(Date.now() / 1000),
): boolean {
let timestamp: number | null = null;
const signatures: string[] = [];
for (const part of (header ?? '').split(',')) {
const [key, value] = part.trim().split('=', 2);
if (key === 't' && /^\d+$/.test(value ?? '')) timestamp = Number.parseInt(value, 10);
if (key === 'v1' && value) signatures.push(value);
}
if (timestamp === null || signatures.length === 0) return false;
if (Math.abs(nowSeconds - timestamp) > TOLERANCE_SECONDS) return false;
const expected = Buffer.from(
createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex'),
);
return signatures.some((candidate) => {
const received = Buffer.from(candidate);
return received.length === expected.length && timingSafeEqual(received, expected);
});
}
// In a Fetch-style handler (Next.js route, Hono, Workers):
export async function POST(request: Request) {
const rawBody = Buffer.from(await request.arrayBuffer());
if (!verifyContentsSignature(rawBody, request.headers.get('Contents-Signature'), process.env.CONTENTS_WEBHOOK_SECRET!)) {
return new Response(null, { status: 400 });
}
const event = JSON.parse(rawBody.toString('utf8'));
// Acknowledge first, then work; de-duplicate on event.id.
return new Response(null, { status: 204 });
}import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_contents_signature(raw_body: bytes, header: str | None, secret: str, now: float | None = None) -> bool:
"""Checks a Contents-Signature header ("t=<unix seconds>,v1=<hex>") against the raw body.
raw_body must be the exact bytes received, before any JSON parsing.
secret is the whole whsec_... string, used as-is (not base64-decoded).
"""
timestamp, signatures = None, []
for part in (header or "").split(","):
key, _, value = part.strip().partition("=")
if key == "t" and value.isascii() and value.isdigit():
timestamp = int(value)
elif key == "v1" and value:
signatures.append(value)
if timestamp is None or not signatures:
return False
if abs((time.time() if now is None else now) - timestamp) > TOLERANCE_SECONDS:
return False
expected = hmac.new(secret.encode("utf-8"), f"{timestamp}.".encode("utf-8") + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected.encode(), candidate.encode()) for candidate in signatures)
# In Flask: request.get_data() is the raw body.
# @app.post("/contents-webhooks")
# def contents_webhook():
# if not verify_contents_signature(request.get_data(), request.headers.get("Contents-Signature"), SECRET):
# abort(400)
# event = request.get_json()
# return "", 204using System.Security.Cryptography;
using System.Text;
public static class ContentsWebhook
{
const long ToleranceSeconds = 300;
/// Checks a Contents-Signature header ("t=<unix seconds>,v1=<hex>") against the raw body.
/// rawBody must be the exact bytes received, before any JSON parsing.
/// secret is the whole whsec_... string, used as-is (not base64-decoded).
public static bool VerifySignature(byte[] rawBody, string? header, string secret, DateTimeOffset? now = null)
{
long? timestamp = null;
var signatures = new List<string>();
foreach (var part in (header ?? "").Split(','))
{
var pieces = part.Trim().Split('=', 3);
var key = pieces[0];
var value = pieces.Length > 1 ? pieces[1] : "";
if (key == "t" && value.Length > 0 && value.All(char.IsAsciiDigit) && long.TryParse(value, out var t)) timestamp = t;
if (key == "v1" && value.Length > 0) signatures.Add(value);
}
if (timestamp is null || signatures.Count == 0) return false;
var nowSeconds = (now ?? DateTimeOffset.UtcNow).ToUnixTimeSeconds();
if (Math.Abs(nowSeconds - timestamp.Value) > ToleranceSeconds) return false;
var prefix = Encoding.UTF8.GetBytes($"{timestamp.Value}.");
var signedPayload = new byte[prefix.Length + rawBody.Length];
prefix.CopyTo(signedPayload, 0);
rawBody.CopyTo(signedPayload, prefix.Length);
var digest = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), signedPayload);
// Lowercase hex, as sent. Convert.ToHexString alone is uppercase and would never match.
var expected = Encoding.UTF8.GetBytes(Convert.ToHexString(digest).ToLowerInvariant());
return signatures.Any(candidate =>
CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(candidate), expected));
}
}
// In an ASP.NET Core minimal API:
// app.MapPost("/contents-webhooks", async (HttpRequest request) =>
// {
// using var buffer = new MemoryStream();
// await request.Body.CopyToAsync(buffer);
// var rawBody = buffer.ToArray();
// if (!ContentsWebhook.VerifySignature(rawBody, request.Headers["Contents-Signature"], secret))
// return Results.BadRequest();
// var evt = JsonDocument.Parse(rawBody);
// return Results.NoContent();
// });Test your verifier
This delivery was signed by the code that signs real deliveries. With now set to t, your function should return true. It should return false at t + 301, or with one byte of the body changed.
| Input | Value |
|---|---|
| Secret | whsec_test_vector_not_a_real_secret_0123456789 |
| t (now) | 1794182400 |
| Header | t=1794182400,v1=6648bbcb91c4648358cf09b13817fde9e2d61b7630d43bafc1412ad1dbcca592 |
{"id":"evt_0a6NnX3I9","object":"event","type":"webhook.ping","livemode":false,"createdAt":"2026-11-09T00:00:00.000Z","data":{"message":"Café test ✓"}}An official SDK
If a generated client does not fit your stack, or you would rather install a maintained package, tell us at integrations@contents.team which language you need.