OAuth callbacks. Sealed webhooks.
cc.me redirects OAuth callbacks to local targets and keeps webhook deliveries encrypted until you claim and acknowledge them.
Use cases
- OAuth callbacks. Register one HTTPS redirect URI and trampoline provider responses to local targets, preserving provider query parameters.
-
Stable aliases. Create reproducible
/c/...URLs for preview apps, branch environments, strict providers, and demos. - Encrypted webhooks. Capture, inspect, forward, claim, ack, release, or fan out deliveries while they remain encrypted until claimed.
- Integrations and tests. Answer common protocol handshakes, verify provider signatures after decrypting, build private fixtures, or hand off offline deliveries by sharing only the private key.
OAuth callbacks
Use / with an absolute at target. Every callback query parameter
except at is appended to the target.
https://cc.me/?at=http%3A%2F%2Fexample.local%2Fauth%2Fcallback
Short aliases
Create a path-only callback URL when a provider wants a stable redirect URI. Reusing the same target returns the same alias.
POST /c
{
"at": "http://example.local/auth/callback"
}
{
"url": "https://cc.me/c/1cuEdvnPA31f"
}
Inbox
/i/<base64url-ed25519-public-key> is a tiny encrypted inbox.
POST stores a request encrypted for that key;
POST /i/<key-a>.<key-b> fans out one encrypted copy per recipient.
POST /i/xG72MCGkluShRByISwEzZVgjIUti3_7phnEch9PxJRI
-
Inboxes hold 100 hooks by default. If any recipient inbox is full,
POSTreturns409 {"error":"inbox is full"}and leaves existing deliveries alone. -
Stored method, path, query, headers, and body must fit within 64 KiB; larger requests
are rejected.
GETand claim responses include only as many items as fit within 1 MiB. -
GET ?l=10&p&c=...peeks without removing deliveries, caps the batch, waits when empty, and uses the returned cursor to read newer deliveries.
Protocol adapters
Adapters live below the inbox URL and answer provider handshakes before storing the full
request encrypted:
/webmention, /websub, /slack,
/pingback, /meta, /cloudevents, and
/discord/<app-public-key>.
POST /i/.../webmention
content-type: application/x-www-form-urlencoded
source=https%3A%2F%2Fexample.com%2Fpost&target=https%3A%2F%2Fexample.net%2Fpage
GET /i/.../websub?hub.mode=subscribe&hub.topic=...&hub.challenge=abc
POST /i/.../slack { "type": "url_verification", "challenge": "3eZbr..." }
POST /i/.../pingback <methodCall><methodName>pingback.ping</methodName>...</methodCall>
GET /i/.../meta?v=secret&hub.mode=subscribe&hub.verify_token=secret&hub.challenge=abc
POST /i/.../cloudevents
ce-specversion: 1.0
ce-id: evt_1
ce-source: https://example.com/source
ce-type: com.example.event
content-type: application/json { "ok": true }
POST /i/.../discord/0123456789abcdef...
x-signature-ed25519: ...
x-signature-timestamp: 1781337600
content-type: application/json { "type": 1 }
Meta returns the plain hub.challenge. CloudEvents accepts binary HTTP mode and
structured JSON. Discord is verified before capture; type: 1 returns
{"type":1}, and other valid interactions are stored with
{"type":5}.
Provider recipes
GitHub, GitLab, Stripe, and Shopify can use the plain inbox URL. After decrypting, verify
the raw bodyBytes with the provider headers before acting.
- GitHub: check
x-hub-signature-256. -
GitLab: prefer Standard Webhooks headers
webhook-id,webhook-timestamp, andwebhook-signature; older hooks may usex-gitlab-token. - Stripe: check
stripe-signature. - Shopify: check
x-shopify-hmac-sha256.
Delivery control
Claim reserves ready deliveries until you ack or release them. If a client disappears after claiming, the service eventually makes the delivery ready again.
-
POST /claimwith{"limit":10,"poll":true}reserves a batch. POST /ackwith{"ids":[...]}removes handled deliveries.POST /releasewith{"ids":[...]}retries them immediately.-
Ack and release return
ackedorreleasedcounts plusmissingIDs.
Owner authentication
GET, claim, ack, and release must prove
ownership of the inbox private key. Each request needs an
x-cc-me-timestamp header (Unix seconds) and an
x-cc-me-signature header containing a base64url Ed25519 signature of:
cc-me-v1
{METHOD}
{PATH}
{TIMESTAMP}
{SHA256(BODY) base64url}
The JS client adds these headers automatically.
Responses
GET /i/xG72MCGkluShRByISwEzZVgjIUti3_7phnEch9PxJRI?l=10&p
x-cc-me-timestamp: 1781337600
x-cc-me-signature: base64url-ed25519-signature
{
"count": 1,
"items": [
{
"id": "m_01k8v7xq7gx1a8p0g7nq0f",
"sealed": "base64url-encrypted-delivery"
}
],
"cursor": "opaque-cursor"
}
Claim returns the same shape without cursor. Decrypted items have this shape:
{
"id": "m_01k8v7xq7gx1a8p0g7nq0f",
"received_at_unix_ms": 1781337600000,
"method": "POST",
"path": "/i/...",
"query": "source=github&delivery=42",
"headers": [
{
"name": "content-type",
"value_b64u": "YXBwbGljYXRpb24vanNvbg"
}
],
"body_b64u": "e30"
}
Clients
Client libraries build trampoline URLs and read inbox deliveries. The JavaScript,
Python, Go, Rust, and Ruby ports share one
wire protocol,
and each ships a cc-me forwarder that claims deliveries and replays them to a
local endpoint. Keys are stored at ~/.cc-me.key by default
(--key or CC_ME_KEY to override).
JavaScript
Forward an inbox to a local endpoint with the
cc-me
command:
npx cc-me http://example.local:8080/webhook
pnpx cc-me http://example.local:8080/webhook
bunx cc-me http://example.local:8080/webhook
deno run -A npm:cc-me http://example.local:8080/webhook
Or inspect an inbox locally before acknowledging deliveries:
npx cc-me inspect
pnpx cc-me inspect
bunx cc-me inspect
deno run -A npm:cc-me inspect
The forwarder persists and reuses ~/.cc-me.key by default. Use
--key to choose a different key file:
npx cc-me --key ~/hooks.key http://example.local:8080/webhook
The ESM package works in browsers, Node.js, Bun, and Deno. In browsers,
privateKey() creates an in-memory key, or you can pass a stored key string.
In server runtimes, privateKey(path) creates and reuses a user-private key
file.
import { homedir } from "node:os";
import { CcMeClient, createAlias, privateKey } from "cc-me";
const alias = await createAlias("http://example.local/auth/callback");
console.log(alias.url);
const key = await privateKey(`${homedir()}/.cc-me.key`);
const cc = new CcMeClient({ privateKey: key });
console.log(await cc.inboxUrl());
const { requests } = await cc.claim({
limit: 10,
poll: true,
});
const handled = [];
for (const request of requests) {
console.log(request.method, request.path, request.text());
handled.push(request.id);
}
await cc.ack(handled);
The inspect subcommand is JavaScript-only.
Python
Install from PyPI (Python 3.10+, PyNaCl), then forward:
pip install cc-me
cc-me http://example.local:8080/webhook
from pathlib import Path
from cc_me import CcMeClient, create_alias, private_key
alias = create_alias("http://example.local/auth/callback")
print(alias.url)
cc = CcMeClient(private_key=private_key(Path.home() / ".cc-me.key"))
handled = []
for request in cc.claim(limit=10, poll=True).requests:
print(request.method, request.path, request.text())
handled.append(request.id)
cc.ack(handled)
Go
Install the forwarder from the cc.me vanity path; the server answers the
go-import handshake, so the installed binary is named cc.me:
go install cc.me@latest
cc.me http://example.local:8080/webhook
The library lives at cc.me/ccme:
import (
"fmt"
ccme "cc.me/ccme"
)
key, _ := ccme.PrivateKey("/path/to/.cc-me.key")
cc, _ := ccme.NewClient("", key)
batch, _ := cc.Claim(ccme.ClaimOptions{Limit: 10, Poll: true})
var handled []string
for _, d := range batch.Requests {
fmt.Println(d.Method, d.Path, d.Text())
handled = append(handled, d.ID)
}
cc.Ack(handled)
Rust
Install the cc-me binary, or add the
cc-me crate with
cargo add cc-me:
cargo install cc-me
cc-me http://example.local:8080/webhook
use cc_me::{CcMeClient, ListOptions, private_key};
use std::path::Path;
let key = private_key(Some(Path::new("/path/to/.cc-me.key")))?;
let cc = CcMeClient::new(key, None)?;
let batch = cc.claim(&ListOptions { limit: Some(10), poll: true, ..Default::default() })?;
let mut handled = Vec::new();
for d in &batch.requests {
println!("{} {} {}", d.method, d.path, d.text());
handled.push(d.id.clone());
}
cc.ack(&handled)?;
Ruby
Install the gem (Ruby 3.0+, RbNaCl with libsodium), then forward:
gem install cc-me
cc-me http://example.local:8080/webhook
require "cc_me"
alias_url = CcMe.create_alias("http://example.local/auth/callback")
puts alias_url.url
key = CcMe.private_key(File.join(Dir.home, ".cc-me.key"))
cc = CcMe::Client.new(private_key: key)
puts cc.inbox_url
handled = []
cc.claim(limit: 10, poll: true).requests.each do |request|
puts [request.method, request.path, request.text].join(" ")
handled << request.id
end
cc.ack(handled)