···11+# API Keys and Authentication
22+33+## Sending requests to Coop
44+55+To authenticate the requests you send to Coop, add an HTTP header to every API request with your organization's API key. You can find or manage your API key in **Settings → API Keys** in the Coop UI.
66+77+Format the header as follows:
88+99+```
1010+X-API-KEY: <<apiKey>>
1111+Content-Type: application/json
1212+```
1313+1414+You can rotate your API key at any time from the same page. After rotating, update any applications or scripts that use the previous key.
1515+1616+## Verifying incoming requests from Coop
1717+1818+To verify that an incoming request to your Action APIs (or other webhooks) was sent by Coop, you can check the request signature. Coop signs each HTTP request it sends to your endpoints and includes the signature in a header. You use a **webhook signature verification key** (public key) to verify that signature.
1919+2020+- Your **webhook signature verification key** is shown in **Settings → API Keys** under "Webhook Signature Verification Key". You can generate a new key there when needed; after rotation, update your verification logic with the new public key.
2121+2222+### Validating requests with the signature header
2323+2424+Coop sends the signature in a `Coop-Signature` header (or `coop-signature` depending on the client). To validate an incoming HTTP request:
2525+2626+1. **Hash the request body** using SHA-256. Use the raw request body (binary) as the input to the hash.
2727+2. **Base64-decode** the value in the `Coop-Signature` header to obtain the raw binary signature.
2828+3. **Verify the signature** using your public key. Coop uses **RSASSA-PKCS1-v1_5** with **SHA-256**: decrypt/verify the signature with your public key and confirm it matches the hash from step 1. Use your language’s crypto library (e.g. Web Crypto, OpenSSL, or standard crypto packages) for RSASSA-PKCS1-v1_5 verification.
2929+3030+### Example (JavaScript / Node)
3131+3232+```javascript
3333+// Your public signing key in PEM format (from Settings → API Keys)
3434+const pem = `-----BEGIN PUBLIC KEY-----
3535+...your key...
3636+-----END PUBLIC KEY-----`;
3737+3838+const pemHeader = "-----BEGIN PUBLIC KEY-----";
3939+const pemFooter = "-----END PUBLIC KEY-----";
4040+const publicKeyPem = pem.substring(
4141+ pemHeader.length,
4242+ pem.length - pemFooter.length
4343+);
4444+4545+const publicKeyBuffer = Buffer.from(publicKeyPem, "base64");
4646+const requestBodyBuffer = Buffer.from(req.body, "utf8");
4747+const signature = Buffer.from(req.headers["coop-signature"], "base64");
4848+4949+const publicKey = await crypto.subtle.importKey(
5050+ "spki",
5151+ publicKeyBuffer,
5252+ { name: "RSASSA-PKCS1-v1_5", hash: { name: "SHA-256" } },
5353+ false,
5454+ ["verify"]
5555+);
5656+5757+const isValid = await crypto.subtle.verify(
5858+ "RSASSA-PKCS1-v1_5",
5959+ publicKey,
6060+ signature,
6161+ requestBodyBuffer
6262+);
6363+```
6464+6565+Adjust header name (`coop-signature` vs `Coop-Signature`) and body encoding to match how your server receives the request.
···6677/**
88 * This service generates + stores key pairs for request signing, which it can
99- * then retrieve.
99+ * then retrieve. It operates under the assumption that the resulting key will
1010+ * be stored somewhere, and then retrieved from storage for signing requests or
1111+ * to show to the user in the UI.
1012 *
1111- * It operates under the assumption that the resulting key will be stored
1212- * somewhere, and then retrieved from storage for signing requests or to show to
1313- * the user in the UI.
1313+ * After rotating, storage (e.g. AWS Secrets Manager) may be eventually
1414+ * consistent, so the next read can still return the old key. We cache the new
1515+ * public key here briefly so the UI refetch/refresh sees the correct key.
1416 */
1717+const ROTATED_KEY_TTL_MS = 10_000;
1818+const recentlyRotatedPublicKeys = new Map<
1919+ string,
2020+ { key: CryptoKey; expiresAt: number }
2121+>();
2222+1523class SigningKeyPairService {
1624 private fetchPrivateKey: Cached<(key: SigningKeyId) => Promise<CryptoKey>>;
1725···5159 }
52605361 /**
5454- * Returns the same public CryptoKey as `createAndStoreSigningKeys`
6262+ * Generates a new key pair, overwrites the stored pair for the org, and
6363+ * invalidates any cached private key so the new key is used for signing.
6464+ * Use this to rotate the webhook signature verification key.
6565+ *
6666+ * @param orgId The org for which to rotate the key pair.
6767+ * @returns The new public key (for exporting to PEM and showing once to the user).
6868+ */
6969+ public async rotateSigningKeys(orgId: string) {
7070+ const keyPair = await this.createSigningKeys();
7171+ await this.store.storeKeyPair({ orgId }, keyPair);
7272+ if (this.fetchPrivateKey.invalidate) {
7373+ await this.fetchPrivateKey.invalidate({ orgId });
7474+ }
7575+ recentlyRotatedPublicKeys.set(orgId, {
7676+ key: keyPair.publicKey,
7777+ expiresAt: Date.now() + ROTATED_KEY_TTL_MS,
7878+ });
7979+ return keyPair.publicKey;
8080+ }
8181+8282+ /**
8383+ * Returns the public key for verification. If we just rotated for this org,
8484+ * we return the new key from memory so the next read is correct even when
8585+ * storage (e.g. AWS Secrets Manager) is eventually consistent.
5586 */
5687 public async getSignatureVerificationInfo(orgId: string) {
8888+ const entry = recentlyRotatedPublicKeys.get(orgId);
8989+ if (entry) {
9090+ if (Date.now() < entry.expiresAt) {
9191+ return entry.key;
9292+ }
9393+ recentlyRotatedPublicKeys.delete(orgId);
9494+ }
5795 return this.store.fetchPublicKey({ orgId });
5896 }
5997
+6
server/utils/caching.ts
···4747 directives?: ConsumerDirectives,
4848 ): Promise<ReadonlyDeep<CachedContentType>>;
4949 close(): Promise<void>;
5050+ /** Invalidates the cached value for the given key. Used when the source data has been replaced (e.g. key rotation). */
5151+ invalidate?(key: KeyType): Promise<void>;
5052};
51535254/**
···147149 }
148150149151 exposedGet.close = async () => getWithCache.cache.close();
152152+ exposedGet.invalidate = async (key: KeyType) => {
153153+ const cacheKey = keyGeneration.toString(key);
154154+ await getWithCache.cache.delete(cacheKey);
155155+ };
150156 return exposedGet;
151157}
152158