Custom app
Create a custom app when One Horizon does not already connect to your tool. From one app, your service can receive events, let users sign in, and call the API.
A custom app can:
- Receive One Horizon events with webhooks.
- Let users sign in with One Horizon.
- Read or update workspace data with the API.
For server-side API calls, create a workspace API key first. See API Keys.
Start from a template
Pick the starter that matches where you deploy:
Each starter stays small and only includes the files needed for that host.
Create the app
- Open Settings → Apps.
- Click Add app.
- Enter an app name.
- Open the app details page.
- Add a homepage, privacy policy, terms URL, and logo if you want them shown in One Horizon.
Use one app per system and environment. Production and staging should not share webhook keys, callback URLs, or delivery logs.
Add webhooks
Use webhooks when your service needs to react to changes in One Horizon. Start a job, sync another system, send a notification, or fetch more context from the API.
- Open your app.
- Click Add webhook.
- Enter the endpoint URL.
- Set a verification key (or click Generate) and store the same value in your receiver as
ONE_WEBHOOK_KEY. Use the field’s help icon if you need details about theX-One-Webhook-Keyheader. - Save the webhook.
- Choose the events you want to receive — pick types individually or toggle all supported types when that fits.
- Open the webhook and click Verify on the URL card. When the check succeeds, verified status and last checked time update.
- Use Delivery logs on that page when something fails. Refresh the list or clear it for a clean view without deleting the webhook. The more menu links to this documentation or deletes the webhook; deleting removes that subscription and its delivery history.
Verify calls your HTTPS URL from One Horizon infrastructure. Use a public endpoint with a valid TLS certificate before you use delivery logs for debugging.
Delivery contract
One Horizon sends each webhook as CloudEvents JSON. The CloudEvents fields stay the same across event types. The data object contains the resource payload for that event.
{ "specversion": "1.0", "id": "evt_...", "type": "task.updated", "source": "onehorizon/workspaces/w_...", "time": "2026-05-05T12:00:00Z", "datacontenttype": "application/json", "subject": "tsk_...", "workspaceid": "w_...", "data": { "resource": { "type": "task", "id": "tsk_...", "workspaceId": "w_..." }, "actor": { "type": "user", "id": "usr_..." }, "task": { "task": {} } }}
Each delivery includes headers for verification, routing, and idempotency. X-One-Webhook-Key is a shared verification key, not a body signature.
Content-Type: application/cloudevents+json; charset=utf-8User-Agent: One-Horizon-Webhooks/1.0X-One-Webhook-Keywhen the webhook has a verification keyX-One-Event-TypeX-One-Event-IdX-One-Retry-Numon retriesX-One-Retry-Reasonon retries
Verify sends HEAD first and falls back to GET when the endpoint returns 405. Event delivery uses POST.
Type and handle events
Install the JavaScript SDK:
npm i @onehorizon/sdk-js@latestAdd a route handler. This example works in a Vercel or Next.js app. It verifies the key, parses JSON, converts the body to the SDK type, and returns 2xx.
import { WebhookEventFromJSON } from '@onehorizon/sdk-js'import type { WebhookEvent } from '@onehorizon/sdk-js' export async function HEAD() { return new Response(null, { status: 204 })} export const GET = HEAD export async function POST(request: Request) { if (!hasValidWebhookKey(request)) { return new Response('Unauthorized', { status: 401 }) } try { const event = parseWebhookEvent(await request.json()) const headerType = request.headers.get('x-one-event-type') if (headerType && headerType !== event.type) { return new Response('Event type mismatch', { status: 400 }) } await handleEvent(event) return Response.json({ received: true, id: event.id }) } catch (error) { return Response.json({ error: errorMessage(error) }, { status: 400 }) }} function hasValidWebhookKey(request: Request) { const expected = process.env.ONE_WEBHOOK_KEY return !expected || request.headers.get('x-one-webhook-key') === expected} function parseWebhookEvent(body: unknown): WebhookEvent { if (!body || typeof body !== 'object' || Array.isArray(body)) { throw new Error('Webhook body must be a JSON object') } const event = WebhookEventFromJSON(body) if ( event.specversion !== '1.0' || event.datacontenttype !== 'application/json' || !event.id || !event.type || !event.data?.resource ) { throw new Error('Invalid One Horizon webhook event') } return event} async function handleEvent(event: WebhookEvent) { console.log('One Horizon webhook received', { id: event.id, type: event.type, resource: event.data.resource })} function errorMessage(error: unknown) { return error instanceof Error ? error.message : 'Invalid webhook'}
Task events already include the task payload. Use API calls for context that is not already in the event. For example, fetch documents attached to the task or initiative. With API keys, use workspaceId: 'current' so you do not have to hardcode a workspace ID.
import { Configuration, DocumentsApi } from '@onehorizon/sdk-js'import type { WebhookEvent } from '@onehorizon/sdk-js' export async function fetchRelatedDocuments(event: WebhookEvent) { const taskId = event.data.resource.taskId || (event.data.resource.type === 'task' ? event.data.resource.id : undefined) const apiKey = process.env.ONE_API_KEY if (!taskId || !apiKey) { return } const documents = new DocumentsApi(new Configuration({ accessToken: apiKey })) const response = await documents.listDocuments({ workspaceId: 'current', taskId, includeContent: true, limit: 5 }) console.log( 'Related documents', response.documents?.map((document) => ({ id: document.documentId, title: document.title, excerpt: document.excerpt })) )}
The event payload is under event.data. Each event type includes one matching resource payload, such as task, comment, team, or issue. Store event.id before you run side effects so retries do not duplicate work.
Add login with One Horizon
Use OAuth when users need to sign in to your app with One Horizon. One Horizon handles the provider step, so your app keeps a single flow. After the user approves your app, they can sign in with any provider enabled in One Horizon, such as Google or GitHub.
- Open your app.
- Add each callback URL on its own line, such as
https://your-app.com/oauth/callback. - Enable OAuth.
- Copy the client ID.
- Regenerate a client secret and store it in your service.
For local development, add http://localhost:3000/oauth/callback as a callback URL and set these environment variables:
ONE_HORIZON_CLIENT_ID=your-client-idONE_HORIZON_CLIENT_SECRET=your-client-secretAPP_BASE_URL=http://localhost:3000
Regenerating the client secret immediately invalidates the old one. Keep the client secret, authorization codes, access tokens, and refresh tokens out of browser code.
OAuth flow
Keep this flow server-side. Use the authorization code flow with PKCE:
- Create a random
stateandcode_verifieron the server. - Store them in a short-lived server-side flow record.
- Redirect the user to One Horizon with
client_id,redirect_uri,scope,state, andcode_challenge. - On the callback, compare the returned
statewith the stored value. - Exchange the
codeandcode_verifierfor tokens from your server.
import { createHash, randomBytes } from 'node:crypto' const oneHorizonBaseUrl = process.env.ONE_HORIZON_BASE_URL || 'https://onehorizon.ai'const appBaseUrl = requiredEnv('APP_BASE_URL').replace(/\/+$/, '')const callbackUrl = `${appBaseUrl}/oauth/callback`const scope = process.env.ONE_HORIZON_SCOPE || 'openid profile email' type OAuthFlow = { codeVerifier: string state: string} type TokenResponse = { access_token: string token_type: string expires_in?: number refresh_token?: string scope?: string} const flows = new Map<string, OAuthFlow>() export function startOneHorizonLogin() { const flowId = randomToken() const state = randomToken() const codeVerifier = randomToken(64) const url = new URL(`${oneHorizonBaseUrl}/app/auth/authorize`) flows.set(flowId, { codeVerifier, state }) url.searchParams.set('client_id', requiredEnv('ONE_HORIZON_CLIENT_ID')) url.searchParams.set('redirect_uri', callbackUrl) url.searchParams.set('response_type', 'code') url.searchParams.set('scope', scope) url.searchParams.set('state', state) url.searchParams.set('code_challenge', codeChallenge(codeVerifier)) return { flowId, redirectUrl: url.toString() }} export async function finishOneHorizonLogin({ code, flowId, state}: { code: string flowId: string state: string}) { const flow = flows.get(flowId) flows.delete(flowId) if (!flow || flow.state !== state) { throw new Error('Invalid or expired OAuth flow') } const response = await fetch(`${oneHorizonBaseUrl}/app/auth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', client_id: requiredEnv('ONE_HORIZON_CLIENT_ID'), client_secret: requiredEnv('ONE_HORIZON_CLIENT_SECRET'), code, code_verifier: flow.codeVerifier, redirect_uri: callbackUrl }) }) if (!response.ok) { throw new Error(`Token exchange failed: ${response.status}`) } return response.json() as Promise<TokenResponse>} function randomToken(bytes = 32) { return randomBytes(bytes).toString('base64url')} function codeChallenge(codeVerifier: string) { return createHash('sha256').update(codeVerifier).digest('base64url')} function requiredEnv(name: string) { const value = process.env[name] if (!value) { throw new Error(`Missing ${name}`) } return value}
This example uses an in-memory Map to keep the code short. In production, store OAuth flow state and sessions in Redis, Postgres, or another persistent store. Put flowId in an HttpOnly, SameSite=Lax, Secure cookie before you redirect the user.
Tools that register themselves with One Horizon create dynamic clients. They appear under Dynamic OAuth clients. Revoke a dynamic client to stop access. Delete it when you no longer need the record.
Event types
Event types use dotted names such as task.created, task.updated, and comment.created. The event picker shows the current list, and the API reference includes an example payload for each event.
Timeouts and retries
Webhook delivery has a 3 second timeout. Return 2xx as soon as you accept the event.
One Horizon sends the first delivery and up to three retries. Retry reasons are:
http_timeoutconnection_failedhttp_error
Retry delays are immediate, then 1 minute, then 5 minutes.
Before you ship
- Keep secrets in your hosting provider, not in code.
- Use separate apps for production and staging.
- Register every production OAuth callback URL.
- Store OAuth flow state and sessions server-side.
- Verify
X-One-Webhook-Key. - Store processed event IDs before side effects.
- Queue slow work instead of doing it inside the request.
- Log event IDs and types, not full payloads.
- Use
workspaceId=currentwith API keys unless you need a fixed workspace ID. - Use the API reference when you need to read or update workspace data.