1Scribe integration guide
Enforce a 1Scribe license across all your channels in about ten lines, with an offline fallback that keeps working through a 1Scribe outage.
1. Get a vendor API key
Vendor API keys authenticate your server-to-server calls (issuing licenses, reading exports). They are separate from the per-customer license tokens your product verifies.
In the dashboard, open Integrations, choose Create key, pick the scopes and copy the secret. The full key (scribe_live_…) is shown exactly once, so store it in your secret manager straight away. We keep only a hash and the prefix and last four characters for display.
Scopes (least privilege: grant only what a key needs):
| Scope | Grants |
|---|---|
entitlements:read | validate/heartbeat on behalf of your product |
licenses:issue | issue licenses for sales made outside Skape |
licenses:revoke | revoke licenses you issued |
export:read | pull signed license and usage exports |
purchases:read | read purchases after accepting the Seller Agreement |
Rotate with Rotate (the old and new keys both work for 24 hours, then the old one stops). Revoke is immediate (within 5 seconds). Both are audit-logged.
2. Validate a license
GET /api/v1/license/validate
Authorization: Bearer <customer license JWT>
200 { valid: true, entitlements: { tier, features, maxSeats, seatsUsed, status, offlineGraceHours, … } } means allow. 403 means deny (invalid, suspended, revoked or expired). The error body is timing-uniform, so do not try to tell the cases apart from the HTTP layer; read entitlements.status from a 200 instead. You may cache a 200 response for up to 15 minutes (honor the Cache-Control header).
3. Heartbeat
POST /api/v1/license/heartbeat
Authorization: Bearer <customer license JWT>
Mirrors validate's entitlement object and adds nextHeartbeatBy. Call it at least once before each nextHeartbeatBy so your offline-grace clock stays fresh. Rate limit: 120 requests a minute for each license.
4. Offline verification (survive 1Scribe outages)
When you cannot reach 1Scribe, verify the license token's signature locally against the published keys and keep working until the token's offlineGraceHours elapse:
GET /.well-known/jwks.json (cache 1h; no auth)
The fallback order is online check, then offline signature check, then grace period. Key rules:
- Verify with RS256 only. Never accept
alg: noneor an HS256 token signed with a public key. Pin the algorithm allowlist. - A token with no
kidheader verifies against thelegacykey entry. - Measure grace from your last successful check, not from token issue time, in production.
5. Error codes
| HTTP | Meaning |
|---|---|
| 200 | valid — read entitlements.status |
| 403 | denied (uniform body; distinguish via a prior 200 payload) |
| 429 | rate limited — honor Retry-After |
| 5xx | 1Scribe unavailable — fall back to offline verification |
6. Bring your own issuer (direct sales)
For licenses you sell outside 1Scribe, you can sign them with your own key and have your verifier accept them alongside 1Scribe-issued licenses, with no per-check round-trip to us.
verifyLicense({
baseUrl: 'https://agentskape.com',
licenseToken: token,
additionalIssuers: [{ issuer: 'vendor-acme', publicKey: YOUR_PUBLIC_PEM, alg: 'RS256' }],
});
Generate a keypair:
openssl genrsa -out issuer_private.pem 2048
openssl rsa -in issuer_private.pem -pubout -out issuer_public.pem
Sign at checkout with your private key and set iss to your registered issuer id. Bind each issuer id to exactly one key: a token claiming a 1Scribe kid must never be accepted by your key, and the other way round (no fallback cascade).
What you give up with self-signed licenses:
| Capability | 1Scribe-issued | Self-signed (BYO) |
|---|---|---|
| Signature verification | ✅ | ✅ |
| Central revocation (≤60s) | ✅ | ❌ (you manage) |
| Key rotation without redeploy | ✅ | ❌ |
| Server-controlled grace policy | ✅ | ❌ |
| Suspension on payment failure | ✅ | ❌ |
| Signed export / audit trail | ✅ | ❌ |
Use BYO only when you handle billing and lifecycle yourself and just need offline signature checks.
7. Issue licenses for sales made outside Skape
Unlike BYO (section 6), this has 1Scribe issue and sign the license for you: the same RS256 token, the same validate, heartbeat and key path, central revocation, and key rotation without a redeploy. Use it when you want one verification integration to cover every channel (the marketplace and your own checkout).
POST /api/v1/vendor/licenses
Authorization: Bearer <vendor key, scope licenses:issue>
Idempotency-Key: <your webhook's own event id — REQUIRED>
Content-Type: application/json
{ "productId": "...", "customerEmail": "buyer@example.com", "seats": 5 }
201 { licenseId, licenseKey, origin: "vendor_direct", entitlements } on first issuance; the same body replays on 200 if you retry with the identical Idempotency-Key (safe for webhook redelivery).
Idempotency-Key is required, not optional. Set it to your own webhook or event id (for example your payment processor's event id), never a random value per attempt. A webhook redelivering the same event with the same key returns the same license; the same key with a different payload is a 409 (a bug in your retry logic, not a race condition to work around).
DELETE /api/v1/vendor/licenses/:id
Authorization: Bearer <vendor key, scope licenses:revoke>
204 on revoke (the refund or chargeback path); 404 if the license is not yours or does not exist (uniform, so there is no existence oracle).
GET /api/v1/vendor/licenses?limit=25&cursor=<last id>
Authorization: Bearer <vendor key, scope entitlements:read>
Cursor-paginated (limit up to 100, default 25), your own direct-issued licenses only.
Error codes specific to this endpoint:
| HTTP | Meaning |
|---|---|
| 400 | missing Idempotency-Key, or validation error (bad email) |
| 404 | product not found / not yours (issue), or license not yours (revoke) |
| 409 | Idempotency-Key reused with a different request body |
| 422 | bad templateId / seats out of the product's configured range |
| 429 | daily issuance limit — honor Retry-After |
Separate from marketplace billing: licenses you issue this way (origin: "vendor_direct") do not flow through Skape's billing, ledgers, invoices or payouts.
8. Webhooks
Register an HTTPS endpoint (in the dashboard, signed in; not available with a vendor API key, so a leaked key can never re-point your webhook) and get push notifications instead of polling:
license.issued | license.revoked | license.suspended | license.restored | license.expiring
license.expiring fires once for each license, 14 days before validUntil, even though the scan runs daily.
Every delivery is a POST to your endpoint with:
X-Scribe-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, `${t}.${rawBody}`)>
Content-Type: application/json
{"eventType":"license.revoked","licenseId":"...","occurredAt":"..."}
Payloads are stateless: no personal data and no entitlement snapshot, just enough to tell you to re-check. Verify the signature before trusting a delivery.
Retries happen after 1 minute, 5 minutes, 30 minutes, 2 hours and 8 hours, then the delivery is marked failed. Your endpoint is disabled (and you get an email) after 3 consecutive failed deliveries; a single flaky failure does not count against you, and the count resets on any successful delivery.
We never follow redirects, and we reject any endpoint URL that resolves to a private, localhost or link-local address, both when you register it and when we deliver.
9. Signed entitlement export (audit, recovery, reconciliation)
GET /api/v1/vendor/export/entitlements?from=<ISO>&to=<ISO>
Authorization: Bearer <vendor key, scope export:read>
Returns { snapshot: { generatedAt, vendorId, rows }, signature: { protected: {alg:"RS256", kid}, jws } }: every one of your own licenses (both origins, each row carries origin), including licenseKey, seats, activations, the organization's billing contact (one email) and event history. It never includes payment data, price paid, member rosters or any other vendor's data.
Limit: 10 requests per hour per vendor; rows are capped at 100,000 (413 past that: contact us). Responses are no-store; do not cache them yourself if you need the freshest state.
The signature is a detached JWS: the payload segment is empty (header..signature) by design, so snapshot stays readable in the response. To verify, re-canonicalize the snapshot you received per RFC 8785 and check it against the published keys (/.well-known/jwks.json), with the same rotation-aware trust as license tokens (retired keys stay published).
10. Purchases API (who bought what)
Availability: this endpoint needs an accepted Seller Agreement and is switched on per environment. It answers 404 until it is switched on for your account's environment.
GET /api/v1/vendor/purchases?productId=<uuid>&purchaseType=subscription,one_time_pass&status=active&updatedSince=<ISO>&cursor=<nextCursor>&limit=50
Authorization: Bearer <vendor key, scope purchases:read>
One list for subscriptions, one-time purchases and legacy Stripe subscriptions, newest change first. Every row says what it is:
purchaseType | Meaning |
|---|---|
subscription | 1Scribe subscription |
one_time_perpetual | one-time purchase, no expiry |
one_time_pass | one-time purchase with a fixed access period |
legacy_stripe_subscription | older Stripe-billed recurring order |
Each row also carries purchaseId, productId, licenseId, status, startedAt, updatedAt, the billing details for its type, any discounts, and the buyer:
"buyer": { "type": "individual", "buyerRef": "opaque-stable-id", "name": "Dana Lee", "email": "dana@example.com" }
The buyer's account name and email are provided for support and upgrade purposes only (Seller Agreement section 6). buyerRef is stable for you and different for every other seller. Only settled orders are listed. A deleted account reads "Deleted user" with email: null. Payment data, internal ids and other sellers' data are never returned.
Requirements: a key created with the purchases:read scope (opt-in; create or rotate a key in Integrations) and an accepted Seller Agreement (403 {"error":"agreement_required"} otherwise). 60 requests per minute per key (429). Page with nextCursor (null on the last page); limit 1 to 100 (default 50); use updatedSince for incremental sync. Webhook payloads never contain buyer names or emails: poll this endpoint and match on licenseId.