Docs / API

API and webhooks overview

1PVN exposes 13 HTTP routes split across three layers: public (no auth, IP rate-limited), staff-gated (session + role required), and system webhooks (secret-verified, provider-triggered). Twelve are documented below; the thirteenth, POST/GET /api/inngest, is the Inngest serve endpoint used internally by the workflow platform and is not a callable integration surface. Every endpoint is multi-tenant-aware — the tenant is resolved from the request subdomain — and all rate limits degrade gracefully when Redis is unavailable.

Purpose: Reference for integrators, tooling authors, and ops staff who call endpoints directly or wire up third-party webhook delivery.

Audience: API integrators, engineers building client tools, ops teams monitoring infrastructure, and the teams at Documenso, Resend, Sentry, and Telegram that send inbound webhooks.

Prerequisites: Familiarity with HTTP status codes and JSON. For webhook setup, access to your provider's webhook configuration UI and the ability to set Fly.io secrets.


Overview

The HTTP surface is divided into three categories:

LayerAuthRate limit keyed onTypical use
PublicNoneClient IPSubmissions, QR codes, form uploads, campaign responses
Staff-gatedSupabase session, role ≥ staffUser IDDocument export, presigned upload URLs, file streaming
System webhooksSecret header, provider-specificNone (provider controls retries)Signature events, email delivery tracking, error alerts, bot commands

Every request is matched to a tenant via the Host (or x-forwarded-host) header before any business logic runs. Requests from app.1pvn.com (platform console) have no tenant context. Requests from gepp.1pvn.com resolve to the GeoPP tenant.

A fourth layer — the bearer-authenticated Workforce API (/api/workforce/*) for automated agents, plus the 1pvn agent CLI — is documented separately in Workforce API and agent CLI.


Endpoint categories

Public endpoints

These endpoints accept requests without a user session. Abuse is bounded by IP-based sliding-window rate limits powered by Upstash Redis. If Redis is unavailable, the limit is bypassed (allow-all) — no request is rejected solely because of a Redis outage.


GET /api/health

Fly.io calls this every 30 seconds to decide whether an instance stays in the load-balancer rotation. Returns a JSON object with an overall status and one entry per dependency.

Response (HTTP 200 when healthy, 503 when unhealthy):

{
  "status": "ok",
  "timestamp": "2026-07-05T12:34:56.789Z",
  "version": "1.2.3",
  "environment": "production",
  "components": {
    "db":         { "status": "healthy" },
    "auth":       { "status": "healthy" },
    "cache":      { "status": "unconfigured" },
    "storage":    { "status": "unconfigured" },
    "email":      { "status": "unconfigured" },
    "workflows":  { "status": "unconfigured" },
    "signatures": { "status": "unconfigured" },
    "ai":         { "status": "unconfigured" }
  }
}

Gate rule: if either db or auth reports unhealthy, the response is HTTP 503 and Fly removes the instance from rotation. Non-critical components (cache, storage, etc.) being unconfigured or degraded does not cause removal.

Component status values: healthy | degraded | unhealthy | unconfigured

Rate limit: None.


POST /api/friction

Captures lightweight UX friction signals (abandoned forms, repeated errors, unclear steps). Stores only context metadata — no form values and no PII.

Request body (JSON):

{
  "module": "consultations",
  "route": "/consultations/new",
  "step": "attendees",
  "signalType": "abandoned_form"
}

Responses: { "ok": true } on success · 400 for invalid JSON or schema · 429 for rate limit · 500 for storage failure.

Rate limit: 30 signals per IP per hour.


GET /api/qr?id=<attendeeId>

Returns a 320×320 px PNG QR code encoding the attendee check-in URL for a specific attendee. Email confirmations embed this as <img src="/api/qr?id=…"> so the QR renders in Gmail and Outlook, which strip data: URIs.

Query params: id — UUID, required.

Response: PNG binary image. Cache-Control: public, max-age=31536000, immutable.

QR payload encodes: https://<tenant-subdomain>.1pvn.com/checkin?id=<attendeeId>

Errors: 400 for non-UUID id · 404 if subdomain cannot be resolved from the request host.

Rate limit: None (image is already public; the attendee ID contains no PII).

Screenshot: attendee confirmation email showing the QR code image rendered inline.


GET /api/comms/respond?token=<token>&value=<response>

Records a campaign response (RSVP, rating, choice, or free-text) from an email link. The response token is a signed JWT with a 30-day expiry minted by the campaigns module.

Query params: token — JWT, required · value — string, max 500 characters, required.

Response: Redirect to /respond/thanks on success.

Errors: 400 for missing params or value over 500 characters · 401 for invalid or expired token · 429 for rate limit · 500 for storage failure.

Rate limit: 20 responses per IP per hour.

Example link in email:

https://gepp.1pvn.com/api/comms/respond?token=eyJ…&value=yes

POST /api/uploads/public

Accepts a file upload from a public form submission and stores it in Cloudflare R2. The form must exist in the database and have isPublic = true — this gate prevents arbitrary writes to R2.

Request: multipart/form-data with fields file (the file) and slug (the form's slug).

Allowed types: application/pdf, image/png, image/jpeg, image/webp.

Max size: 10 MB.

Response: { "fileKey": "…", "fileName": "…" }.

Error messages:

ConditionStatusMessage
Missing slug400"Missing form slug"
Form not found or not public404"Form not found"
No file in request400"No file provided"
File over 10 MB400"File too large (max 10MB)"
Wrong file type400"Use a PDF or image (PNG, JPG, WebP)"
Rate limit429"Too many uploads. Please try again later."
Storage failure500"Upload failed. Please try again later."

Rate limit: 20 uploads per IP per hour.


Staff-gated endpoints

All staff-gated endpoints require a valid Supabase session with role ≥ staff. A session cookie set by the login flow is enough in the browser. For CLI or server-to-server calls, pass Authorization: Bearer <access_token>.

Role check behavior:

  • No session → HTTP 401
  • Session with insufficient role → HTTP 403

POST /api/uploads/presigned

Generates a temporary S3-compatible signed URL for staff to upload a file directly to Cloudflare R2. The URL expires in one hour.

Request body (JSON):

{
  "filename": "report.pdf",
  "contentType": "application/pdf",
  "module": "documents",
  "entityId": "550e8400-e29b-41d4-a716-446655440000"
}

Allowed modules: submissions, documents, stakeholders.

Allowed content types: PDF, DOCX, DOC, PNG, JPEG, WebP, CSV, Excel (XLS, XLSX).

Response: { "uploadUrl": "https://…", "fileKey": "…" }

Errors: 400 for missing fields, invalid module, or unsupported content type · 401 / 403 for auth · 429 for rate limit · 503 if storage is not configured.

Rate limit: 30 uploads per user per hour (keyed on user ID, not IP).

Two-step upload example:

# Step 1 — request a signed URL (requires auth)
curl -X POST https://gepp.1pvn.com/api/uploads/presigned \
  -H "Authorization: Bearer <access_token>" \
  -H "Content-Type: application/json" \
  -d '{"filename":"report.pdf","contentType":"application/pdf","module":"documents","entityId":"550e8400-e29b-41d4-a716-446655440000"}'

# Response: { "uploadUrl": "https://r2.../...", "fileKey": "documents/550e8400-…/report.pdf" }

# Step 2 — upload directly to R2 (no auth header needed; URL is self-authenticating)
curl -X PUT "https://r2.cloudflare.com/…?<signed-params>" \
  -H "Content-Type: application/pdf" \
  --data-binary @report.pdf

GET /api/export/pdf?id=<docId>

Generates and downloads a formatted PDF for a document record. The PDF includes a header with the 1PVN logo and date, document type and status, title, content (headings and paragraphs), version history (up to 10 versions), and page numbers.

Query params: id — document UUID, required.

Response: PDF binary with Content-Disposition: attachment; filename="<title>.pdf".

Errors: 400 for missing ID · 401 / 403 for auth · 404 for document not found · 429 for rate limit.

Rate limit: 10 exports per user per hour (PDF generation is CPU-heavy).


GET /api/documents/[id]/file

Streams the stored PDF for a document from Cloudflare R2. Used by the in-app signature field placement editor (react-pdf) to render the document for drag-and-drop field placement. The response is Cache-Control: private, no-store.

Route param: id — document UUID.

Response: PDF binary stream.

Errors: 401 / 403 for auth · 404 for document not found, archived, or having no stored file · 429 for rate limit · 503 if the database is unavailable.

Rate limit: 60 downloads per user per hour.


Authentication and roles

Public endpoints

No authentication. Identity is tracked by IP address extracted from the x-forwarded-host header (set by Fly.io) or the standard Host header.

Staff-gated endpoints

Requires a valid Supabase session (JWT). The backend calls requireRole("staff", { module: "…" }) which:

  1. Calls getUser() to validate the session.
  2. Checks ROLE_HIERARCHY[user.role] >= ROLE_HIERARCHY.staff (hierarchy: public 0 < lgu 1 < staff 2 < reviewer 3 < lead 4 < admin 5).
  3. Returns the authenticated user on success or throws an AuthError on failure.
  4. Tags Sentry with the module and user ID for attribution.

Role is per-tenant: the same person can be admin at one chamber and staff at another.

Webhook endpoints

Each provider uses a different verification method. All webhook handlers fail closed in production — missing secrets return an error rather than bypassing verification.

WebhookHeaderMethod
Documensox-documenso-secretPlain-text, constant-time compare
Resendsvix-signature + svix-timestampHMAC-SHA256 of <timestamp>.<body>, 5-min replay window
Sentrysentry-hook-signatureHMAC-SHA256 of body
Telegramx-telegram-bot-api-secret-tokenPlain-text, constant-time compare; also checks chat.id

Rate limiting

All rate limits use Upstash Redis with a sliding-window algorithm. If Redis is unavailable, the check returns success: true (requests are allowed through). Rate-limit exceeded returns HTTP 429.

EndpointLimitWindowKeyed on
/api/friction301 hourIP
/api/comms/respond201 hourIP
/api/uploads/public201 hourIP
/api/uploads/presigned301 hourUser ID
/api/export/pdf101 hourUser ID
/api/documents/[id]/file601 hourUser ID

Multi-tenant host resolution

Every request is routed to a tenant before any business logic runs. The resolver reads the x-forwarded-host header (Fly.io sets this) or falls back to Host.

Classification logic:

  1. Extract the subdomain from the host (e.g., gepp.1pvn.comgepp).
  2. Classify:
    • Tenant subdomain — single-label subdomain (gepp, eccp, demo-chamber) → look up in the tenants table.
    • Platform subdomain — reserved labels (app, www) or nested subdomains → no tenant context (console only).
    • Foreign host*.fly.dev, localhost, or anything else → may fall back to TENANT_FALLBACK_SUBDOMAIN env var if set (development and demo only; unset in production).
  3. If no tenant resolves and no fallback is configured, queries fail closed (no tenant context = no data).

Webhook handlers determine tenant from event context (for example, the Documenso handler looks up the document's tenant by external_submission_id; it does not rely on the request host).


Webhooks

Documenso — e-signature events

Endpoint: POST /api/webhooks/documenso

Verification: x-documenso-secret header must match DOCUMENSO_WEBHOOK_SECRET. Comparison is constant-time. In production, a missing secret returns HTTP 500 (fail-closed). In non-production, verification is skipped with a warning.

Handled events:

Documenso eventInternal statusWhat happens
DOCUMENT_SENTsentsignatures.status set to 'sent'
DOCUMENT_SIGNEDpartially_signedsignatures.status set to 'partially_signed'; signer's signedAt timestamp recorded
DOCUMENT_COMPLETEDcompletedSigned PDF downloaded from Documenso and stored to R2; document locked (is_locked = true); opportunity owner notified

Ignored events (acknowledged with HTTP 200 to stop Documenso retries): CREATED, OPENED, REJECTED, CANCELLED.

Example payload (DOCUMENT_COMPLETED):

{
  "event": "DOCUMENT_COMPLETED",
  "payload": {
    "id": 12345,
    "status": "COMPLETED",
    "recipients": [
      { "email": "alice@eccp.com", "signingStatus": "SIGNED", "signedAt": "2026-07-05T10:30:00Z" },
      { "email": "bob@example.com", "signingStatus": "SIGNED", "signedAt": "2026-07-05T11:15:00Z" }
    ]
  }
}

Screenshot: Documenso webhook configuration UI showing the webhook URL and secret field.


Resend — email delivery tracking

Endpoint: POST /api/webhooks/resend

Verification: svix-signature and svix-timestamp headers. The signature is HMAC-SHA256 of <timestamp>.<raw body> encoded as base64. Timestamps older than 5 minutes are rejected to prevent replay attacks.

Secret: RESEND_WEBHOOK_SECRET. In production, a missing secret returns HTTP 500. In non-production, verification is skipped with a warning.

Handled events:

Resend eventStatus storedWhere
email.delivereddeliveredemail_log
email.openedopenedemail_log + campaign_recipients
email.clickedclickedemail_log + campaign_recipients
email.bouncedbouncedemail_log + campaign_recipients
email.complainedbouncedemail_log + campaign_recipients

Unhandled events are acknowledged with HTTP 200 (no retry). Campaign recipient tracking (campaign_recipients) is best-effort — an error there does not fail the webhook response.


Sentry — error and issue alerts

Endpoint: POST /api/webhooks/sentry

Verification: sentry-hook-signature header. HMAC-SHA256 of the raw body, compared in constant time. In production, a missing secret returns HTTP 500. In non-production, verification is skipped.

Secret: SENTRY_WEBHOOK_SECRET.

Processing:

  1. Classifies severity: fatal level or severity=critical tag → critical; error level → error; everything else → warning.
  2. Classifies layer: checks issue tags['layer'], then culprit path patterns (/api/api_route, actionserver_action, middlewaremiddleware, default → server_component).
  3. Logs to app_errors table with sentryEventId, errorType, message, layer, path, severity, and metadata.
  4. Forwards to Telegram with severity-appropriate alert level (critical → critical alert, error → medium, warning → low).

Response: { "received": true, "logged": true, "severity": "error" } (example).


Telegram — bot commands and alerts

Endpoint: POST /api/webhooks/telegram

Verification:

  1. x-telegram-bot-api-secret-token header must match TELEGRAM_WEBHOOK_SECRET (constant-time compare). In production, a missing secret returns HTTP 401.
  2. message.chat.id must match TELEGRAM_ALERT_CHAT_ID. Messages from unauthorized chats are silently ignored (HTTP 200 returned to prevent Telegram retries).

Always returns HTTP 200 — Telegram retries on any non-200 response.

Handled commands (routed asynchronously, fire-and-forget):

CommandWhat it does
/statusInfrastructure health: DB, Redis, recent error rates
/helpLists available commands
/infraDeployment info: regions, Fly resources
/errorsRecent errors from app_errors table
/fixApply hotfix for critical outages
/bugsRecent bugs filed in the issues module
/dealsPipeline opportunity summary
/approveApproval queue status
/authCurrent session / identity info

Alert forwarding: the Sentry webhook handler calls sendAlert() on the Telegram client after logging to app_errors. The Telegram client uses TELEGRAM_BOT_TOKEN to send messages via the Bot API and TELEGRAM_ALERT_CHAT_ID to target the ops channel.

Screenshot: Telegram ops channel showing a /status command response.


Health checks

Fly.io polls /api/health every 30 seconds using its [http_service.checks] configuration. The response is always JSON — the machine either stays in rotation (HTTP 200, status: "ok") or is removed (HTTP 503, status: "unhealthy").

Decision rule: only db and auth components drive the HTTP status code. Non-critical components (cache, storage, email, workflows, signatures, ai) are reported for observability but do not affect the 200/503 decision.

If an instance is removed: Fly restarts it automatically. If the problem persists across all instances, the deployment is unhealthy — check Supabase status and your environment variables.


Examples

Test the health endpoint

curl https://gepp.1pvn.com/api/health

Expected: HTTP 200 with "status": "ok" when all is well.

Submit a friction signal

curl -X POST https://gepp.1pvn.com/api/friction \
  -H "Content-Type: application/json" \
  -d '{"module":"pipeline","route":"/pipeline/new","step":"details","signalType":"abandoned_form"}'

Expected: { "ok": true }.

Get a presigned upload URL (staff)

# Requires a valid session token from Supabase Auth
curl -X POST https://gepp.1pvn.com/api/uploads/presigned \
  -H "Authorization: Bearer <access_token>" \
  -H "Content-Type: application/json" \
  -d '{"filename":"proposal.pdf","contentType":"application/pdf","module":"documents","entityId":"<uuid>"}'

Expected: { "uploadUrl": "https://…", "fileKey": "documents/<uuid>/proposal.pdf" }.

Upload a file to R2 with the signed URL

curl -X PUT "<uploadUrl-from-previous-step>" \
  -H "Content-Type: application/pdf" \
  --data-binary @proposal.pdf

Expected: HTTP 200 with an empty body (R2 returns no body on success).


Best practices

For public endpoint callers

  • Implement retry with exponential backoff for 5xx errors — infrastructure failures are transient.
  • Handle 429 gracefully — back off and surface a "too many requests" message to the user rather than retrying immediately.
  • Do not embed tracking codes or form slugs in client-side code that is not yours — if your IP gets rate-limited, all users behind that IP are affected.

For webhook senders (Documenso, Resend, Sentry, Telegram)

  • Verify signatures first, before parsing the JSON body — never process a payload that hasn't been authenticated.
  • Return HTTP 200 as soon as verification passes, even if your processing fails asynchronously. Providers retry on non-200; duplicate retries on a slow but successful handler cause duplicate side effects.
  • Implement idempotency — providers may retry a webhook event. Use the event's email_id, external_submission_id, or similar field to detect duplicates.

For staff-gated endpoint callers

  • Presigned URLs are single-use and expire in one hour. Request a fresh URL for each upload session — do not cache and reuse them.
  • Request the presigned URL immediately before the upload, not minutes earlier. A URL that expires before the upload starts will result in a 403 from R2.

Warnings

  • Never commit webhook secrets (DOCUMENSO_WEBHOOK_SECRET, RESEND_WEBHOOK_SECRET, SENTRY_WEBHOOK_SECRET, TELEGRAM_WEBHOOK_SECRET) to Git. Store them as Fly.io secrets: flyctl secrets set KEY=value.
  • Webhook verification is fail-closed in production. A missing secret returns 500, not 200 — it is deliberate that unverified webhook payloads cannot enter the system.
  • Public endpoint IP rate limits cover shared addresses. A corporate network, VPN, or school WiFi with many users will share one IP and may hit limits faster than expected. If this is a recurring problem, contact Ops.
  • QR codes are public. Anyone who receives the confirmation email can share the QR image. Check-in is idempotent — scanning twice marks the attendee as attended once, it does not create a second check-in.
  • Subdomain routing is the tenant boundary. Misconfigured DNS or reverse-proxy headers that forge the subdomain can route requests to the wrong tenant. Validate your DNS configuration at the infrastructure layer.

Troubleshooting

Health check returns unhealthy (HTTP 503)

Symptom: Fly removes instance from the load balancer; /api/health returns "status": "unhealthy".

Cause: db or auth component is reporting unhealthy.

Fix:

  1. Check the Supabase status page for the project region.
  2. Verify DATABASE_URL and NEXT_PUBLIC_SUPABASE_URL are set correctly: flyctl secrets list | grep SUPABASE.
  3. Restart the app: flyctl apps restart <app-name>.
  4. If the problem persists, check Fly logs: flyctl logs.

Rate limit exceeded (HTTP 429) on a public endpoint

Symptom: Response body contains "Rate limit exceeded" or "Too many uploads. Please try again later.".

Cause: Same IP has crossed the per-hour threshold.

Fix:

  • Immediate: Wait for the 1-hour sliding window to clear, or switch to a different network (mobile hotspot).
  • Integrators: Use staff-gated presigned uploads (/api/uploads/presigned) which are scoped per-user (30/hour) rather than per-IP (20/hour).
  • Persistent issues: Contact Ops via Telegram /status to check if one IP is hammering the endpoint and temporarily adjust via Redis.

Webhook signature verification fails (HTTP 401)

Symptom: Webhook handler returns "Invalid signature" or "Invalid secret"; provider retries the webhook repeatedly.

Cause:

  1. Secret env var not set or mismatched between provider and app.
  2. Provider rotated the secret but the app was not updated.
  3. Body modified in transit (uncommon).

Fix:

  1. Check that the secret is set: flyctl secrets list | grep WEBHOOK.
  2. Open the provider UI (Documenso / Resend / Sentry) and confirm the secret matches.
  3. To rotate: regenerate the secret in the provider UI, update Fly: flyctl secrets set DOCUMENSO_WEBHOOK_SECRET=<new>, then redeploy.
  4. Temporarily disable the webhook in the provider UI while rotating to stop retries.

Presigned URL expired before upload completed (HTTP 403 from R2)

Symptom: R2 returns HTTP 403 after you call PUT <uploadUrl>.

Cause: The signed URL expired (1-hour window) or was already used.

Fix:

  1. Call /api/uploads/presigned again to get a fresh URL.
  2. Start the upload immediately — do not wait between requesting and using the URL.

Documenso webhook received but document status not updated

Symptom: Webhook logs show success: true but signatures.status is still sent.

Cause: Documenso fires DOCUMENT_SIGNED when each individual signer completes. The final lock-and-download only happens on DOCUMENT_COMPLETED (all signers done).

Fix:

  1. Check the Documenso webhook logs in Settings → Webhooks for the event type.
  2. If you see only DOCUMENT_SENT and no DOCUMENT_SIGNED, the signer has not yet opened or signed the email.
  3. Use Documenso's "Resend" feature to send the signer a reminder.

Resend webhook not updating campaign open/click rates

Symptom: Campaign open/click rates show 0% despite successful delivery.

Cause:

  1. Resend webhook not configured or secret mismatch.
  2. Email clients with privacy protection (Apple Mail Privacy Protection, many corporate clients) do not fire open/click tracking pixels.

Fix:

  1. Check Resend dashboard → Webhook Logs for recent delivery events.
  2. Verify RESEND_WEBHOOK_SECRET is set in Fly secrets.
  3. Expect open and click rates of 10–30% in practice — privacy features suppress most tracking at the mail-client level. Delivery counts are more reliable.

FAQ

Q: Can I use the same presigned URL for multiple files? No. Each presigned URL is single-use and bound to one specific file path. Call /api/uploads/presigned once per file.

Q: Do all endpoints require HTTPS? Yes. 1PVN redirects HTTP to HTTPS at the Fly proxy level. Webhook senders must use https:// URLs.

Q: Can I test webhooks locally? Webhook endpoints require a public HTTPS URL with valid DNS. For local testing, use a tunneling service such as ngrok or Cloudflare Tunnel to expose localhost:3000.

Q: What happens if a webhook event is delivered twice? The app handles most duplicates. Documenso and Resend webhook processing uses external IDs (external_submission_id, email_id) to identify the record, so a retry updates the same row rather than creating a duplicate. Telegram commands are fire-and-forget; a retry would re-run the command.

Q: What is the difference between public and staff file uploads? /api/uploads/public is IP-limited, accepts only PDF and images (PNG/JPEG/WebP), and requires a public form slug. /api/uploads/presigned is user-limited, also accepts DOCX, DOC, CSV, and Excel, and requires a staff session.

Q: How do I rotate a webhook secret? Regenerate the secret in the provider UI, then: flyctl secrets set <SECRET_KEY>=<new-value>. Redeploy the app. The old webhook URL will return 401 until the deploy completes — disable the webhook in the provider UI during the transition to stop retries.

Q: What does HTTP 503 on /api/uploads/presigned mean? Cloudflare R2 storage is not configured in this environment (missing CLOUDFLARE_R2_* env vars). This is expected in local development without storage setup.


Related articles

  • System architecture overview — how subdomains map to tenants, the platform services behind these endpoints
  • Roles and access — the role hierarchy, what staff/reviewer/lead/admin can do, and how new users are activated
  • Sending a document for e-signature — the staff-facing workflow behind the Documenso webhook
  • Dedicated references for storage/uploads, rate limiting, and Inngest workflows are planned for Wave 3 — see the roadmap.