cc.me

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

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

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.

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.

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)

Stats

Redirects, 48h

Inboxes, 48h

Inboxed, 48h

Redirects, 30d

Inboxes, 30d

Inboxed, 30d