Shoninshonin

Overview

Shonin is an open source, human-in-the-loop approval API. Send an approval request to any email address and wait for a human decision before your automation continues. Approvers need no account: they just click a link.

Base URLhttps://shonin.dev/api/v1

All requests must include an Authorization: Bearer <api_key> header, except the /v1/decide/:token links in approval emails, which approvers open and which need no key.

Shonin is MIT licensed. Try the hosted demo at shonin.dev, or run your own copy (see ).

Quickstart

  1. Sign in with your email at /login. Your API key is on your dashboard and is emailed to you.
  2. Send an approval request. Use your own email as the approver: the hosted demo only sends to your account email.
bash
curl -X POST https://shonin.dev/api/v1/approvals \
  -H "Authorization: Bearer sk_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "Deploy to production",
    "approver_email": "you@example.com"
  }'
  1. Open the email and click Approve, then read the result with the id from the response:
bash
curl https://shonin.dev/api/v1/approvals/APPROVAL_ID \
  -H "Authorization: Bearer sk_your_api_key"

The status field changes from pending to approved or rejected.

Authentication

Pass your API key as a Bearer token in the Authorization header on every request. Sign in at /login to see your key.

bash
curl -H "Authorization: Bearer sk_your_api_key" \
  https://shonin.dev/api/v1/approvals/APPROVAL_ID
Keep your API key secret. Do not expose it in client-side code or public repositories.

Create Approval

POST/v1/approvals

Creates a new approval request and sends an email to the approver with Approve and Reject buttons. Returns immediately. The approval stays pending until the approver decides.

Request body

NameTypeRequiredDescription
actionstringrequiredA short description of what needs approval. Shown prominently in the email.
approver_emailstringrequiredThe email address of the person who will approve or reject.
contextstringoptionalOptional extra context displayed in the email below the action.
webhook_urlstringoptionalPublic https URL to POST the decision to when the approver clicks Approve or Reject. Private and reserved addresses are rejected with a 400.
expires_in_hoursnumberoptionalHow many hours before the approval link expires. Defaults to 24.
command_typestringoptionalWhat kind of action this is, for example git_push_force or sql_drop. Drives the risk banner in the email. Inferred from action when omitted. See Risk Levels.
filesarrayoptionalFiles the action touches, for example [{ "path": "app/route.ts", "status": "modified" }]. Status is modified, added, deleted or renamed.
diffstringoptionalA diff to show the approver. Truncated at 50KB.

Example request

bash
curl -X POST https://shonin.dev/api/v1/approvals \
  -H "Authorization: Bearer sk_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "Deploy to production",
    "approver_email": "cto@company.com",
    "context": "PR #247 merged, 3 files changed",
    "webhook_url": "https://yourapp.com/webhooks/shonin"
  }'

Example response 201 Created

json
{
  "id": "a1b2c3d4-...",
  "status": "pending",
  "created_at": "2026-03-21T18:00:00Z",
  "expires_at": "2026-03-22T18:00:00Z"
}

Get Approval

GET/v1/approvals/:id

Returns the current state of an approval. Use this to poll for a decision if you are not using webhooks. Only approvals belonging to the authenticated account are returned.

Example request

bash
curl https://shonin.dev/api/v1/approvals/a1b2c3d4 \
  -H "Authorization: Bearer sk_your_api_key"

Example response 200 OK

json
{
  "id": "a1b2c3d4-...",
  "action": "git push origin main --force",
  "context": "Remote diverged after a rebase",
  "approver_email": "cto@company.com",
  "status": "approved",
  "webhook_url": null,
  "command_type": "git_push_force",
  "risk_level": "DESTRUCTIVE",
  "risk_bullets": [
    "Will overwrite upstream commits",
    "Bypasses branch protection rules"
  ],
  "files": [{ "path": "app/route.ts", "status": "modified" }],
  "expires_at": "2026-03-22T18:00:00Z",
  "decided_at": "2026-03-21T18:45:00Z",
  "created_at": "2026-03-21T18:00:00Z"
}

Risk Levels

When an approval has a command_type, the email opens with a risk banner so the approver can see how reversible the action is. If you leave command_type out, Shonin infers it from action when it recognizes commands such as git push --force, git reset --hard, rm, drop table or a migration.

command_typeLevelShown to the approver
git_push_forceDESTRUCTIVEWill overwrite upstream commits. Bypasses branch protection rules.
git_reset_hardDESTRUCTIVELocal changes will be permanently lost.
rmDESTRUCTIVEFiles cannot be recovered from trash.
sql_dropDESTRUCTIVETable data is permanently deleted.
sql_migrationHIGHSchema changes may be irreversible.
git_pushLOWReversible via git revert.
git_commitLOWReversible via git reset.
anything elseLOWNo banner text.
DESTRUCTIVE approvals add a 3 second countdown on the confirm page before the Approve button unlocks.

Create Decision

POST/v1/decisions

Ask a multiple-choice question instead of a yes or no. The respondent gets an email with one button per option and picks one.

Request body

NameTypeRequiredDescription
questionstringrequiredThe question to ask.
optionsarrayrequired2 to 10 options, each { "key": "prod", "label": "Production" }. A key is up to 16 characters and a label up to 200.
respondent_emailstringrequiredThe email address of the person who will answer.
contextstringoptionalOptional extra context shown in the email.
webhook_urlstringoptionalPublic https URL to POST the answer to.
expires_in_hoursnumberoptionalHow many hours before the links expire. Defaults to 24.

Example request

bash
curl -X POST https://shonin.dev/api/v1/decisions \
  -H "Authorization: Bearer sk_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "question": "Which environment should we deploy to?",
    "options": [
      { "key": "staging", "label": "Staging" },
      { "key": "prod", "label": "Production" }
    ],
    "respondent_email": "cto@company.com"
  }'

Example response 201 Created

json
{
  "id": "e5f6a7b8-...",
  "status": "pending",
  "created_at": "2026-03-21T18:00:00Z",
  "expires_at": "2026-03-22T18:00:00Z"
}

Get Decision

GET/v1/decisions/:id

Returns the current state of a decision. status is pending or decided, and chosen_key holds the key of the option that was picked.

json
{
  "id": "e5f6a7b8-...",
  "status": "decided",
  "chosen_key": "prod",
  "decided_at": "2026-03-21T18:45:00Z",
  "expires_at": "2026-03-22T18:00:00Z",
  "created_at": "2026-03-21T18:00:00Z"
}

Webhooks

Set webhook_url on an approval or a decision and Shonin sends a POST with a JSON body once the decision is recorded.

Approval

json
{
  "id": "a1b2c3d4-...",
  "status": "approved",
  "decided_at": "2026-03-21T18:45:00Z"
}

Decision

json
{
  "id": "e5f6a7b8-...",
  "status": "decided",
  "chosen_key": "prod",
  "decided_at": "2026-03-21T18:45:00Z"
}
  • The URL must be a public https address. Private and reserved addresses are rejected with a 400 when you create the request.
  • Shonin waits 5 seconds for a response, does not follow redirects, and sends each webhook once with no retries.
Webhook payloads are not signed. Treat one as a signal and confirm the result with a GET request before you act on it.

How Decisions Work

You never call the decide endpoint yourself. It sits behind the links in the email, and it is built so the code that asks for approval cannot give it.

  • The approve and reject links exist only in the email to the approver. The API never returns them, so your code cannot approve its own request.
  • Opening a link never decides. It leads to a confirm page, and the decision is recorded only when the approver confirms, which sends a POST. Mail scanners and link previews that open every link cannot decide.
  • Links are single-use and expire after 24 hours unless you set expires_in_hours.

Errors

All errors return a JSON object with an error field containing a human-readable message.

json
{ "error": "Invalid API key" }

Error codes

StatusMeaningCommon cause
400Validation errorA required field is missing, a value has the wrong type, or webhook_url is not a public https address.
401Invalid API keyMissing or incorrect Authorization header.
404Not foundThe ID does not exist or belongs to a different account.

Hosted demo only

The hosted demo is capped to protect a shared email allowance. Self-hosted instances do not apply these limits.

StatusMeaningCommon cause
403Recipient not allowedapprover_email or respondent_email is not the email on your account.
429Daily limit reachedYour key has used its requests for the day.
503Demo budget usedThe shared email budget for today is used up. Try again tomorrow.

Self-Hosting

Shonin is MIT licensed and runs on Next.js, Supabase and Resend. A self-hosted instance has no request limits, and it serves this same documentation at /docs.

  1. Clone the repository and run npm install.
  2. Create a Supabase project and run supabase/schema.sql in its SQL editor.
  3. Copy .env.example to .env.local and fill in your Supabase and Resend values. Set EMAIL_FROM_DOMAIN to your verified Resend domain.
  4. In Supabase, set your app URL as the Site URL and as a redirect URL so login works.
  5. Insert your first API key, then run npm run dev.

The full steps, including the SQL for your first key, are in the README on GitHub.