Client libraries

There is no hand-written SDK to install. The OpenAPI document is the contract, so you can generate a typed client for your own stack in a minute. The part people most often get wrong is checking webhook signatures, so this page has a tested verifier in each language too.

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 ClaimsPostRequestBody or CreateClaimBody), not from a shared model list.
  • Every POST needs an Idempotency-Key header. 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_... or ctk_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.

Generate
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.ts
TypeScript
import 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

Generate
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.Bundle
C#
using 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 (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.

Generate
pipx run openapi-python-client generate --url https://contents.team/api/v1/openapi.json
pip install ./contents-team-api-client
Python
import 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_cursor

These 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:

  1. 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.
  2. Split the header on commas. t is the signing time in Unix seconds. There can be more than one v1, and any one that matches passes.
  3. 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.
  4. Hex-encode the result in lowercase and compare it with each v1 in constant time.
  5. Reject the delivery if t is 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.

TypeScript (Node.js)
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 });
}
Python
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 "", 204
C#
using 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.

InputValue
Secretwhsec_test_vector_not_a_real_secret_0123456789
t (now)1794182400
Headert=1794182400,v1=6648bbcb91c4648358cf09b13817fde9e2d61b7630d43bafc1412ad1dbcca592
Raw body (exact bytes, UTF-8)
{"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.

NextTest modeBuild against canned results with test keys; nothing is billed.