Neon
Run Social SDK inside a Neon Function next to Neon Postgres, and reconcile deliveries on a scheduled Function Trigger.
Neon Functions run Node.js 24, one of the SDK’s tested runtimes, so the SDK runs inside the function itself. The function runs in the same region as its branch’s Postgres and gets DATABASE_URL injected. Reconciliation runs on a scheduled Function Trigger, so you don’t need an outside scheduler. Connected accounts, publication records, delivery references, and the webhook inbox live in your own Postgres tables.
Install
npm install @opencoredev/social-sdkpnpm add @opencoredev/social-sdkyarn add @opencoredev/social-sdkbun add @opencoredev/social-sdknub add @opencoredev/social-sdkaube add @opencoredev/social-sdkThe examples also use hono, pg, @neon/functions 0.8.0 or later, and @neon/config.
Declare the function
Declare the function and its reconciliation schedule in neon.ts. The slug becomes part of the public URL and can’t be changed after the first deploy.
import { defineConfig } from "@neon/config/v1";
export default defineConfig({
functions: {
social: {
name: "social publishing",
source: "src/index.ts",
env: {
THREADS_USER_ID: process.env.THREADS_USER_ID!,
THREADS_ACCESS_TOKEN: process.env.THREADS_ACCESS_TOKEN!,
},
},
},
triggers: {
"social-reconcile": {
type: "schedule",
function: "social",
cron: "*/10 * * * *",
functionPath: "/reconcile",
},
},
});
env values are read when neon deploy evaluates the file, so load them from a local file that stays out of version control. Add ZERNIO_WEBHOOK_SECRET the same way if you use the webhook route below:
neon deploy --env .env.production
Each deployment keeps its own snapshot of these variables. Changing a token means deploying again.
Schema
create table connected_accounts (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references tenants (id),
platform text not null,
account_id text not null,
backend text not null default 'default',
created_at timestamptz not null default now(),
unique (tenant_id, platform, account_id)
);
create table deliveries (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references tenants (id),
idempotency_key text not null,
state text not null,
delivery_ref jsonb,
created_at timestamptz not null default now(),
unique (tenant_id, idempotency_key)
);
create index deliveries_pending on deliveries (state)
where state in ('processing', 'unknown');
create table idempotency_claims (
claim_id uuid primary key default gen_random_uuid(),
scope text not null,
key text not null,
fingerprint text not null,
target_keys jsonb not null,
outcomes jsonb not null default '{}',
unique (scope, key)
);
create table threads_workflows (
id text primary key,
workflow jsonb not null,
claimed_until timestamptz
);
tenants is the table your application already uses for tenant identity, keyed by the same ID your authentication resolves. Create it before these tables if you don’t have one.
Tokens do not belong in these tables. The function connects as the database owner, so every query must filter by the tenant you verified. Connection attempts and stored OAuth credentials hold secrets too, such as PKCE verifiers, provider state, and refresh tokens. If you store them in Postgres, keep them in separate tables scoped to the tenant and principal, encrypt the credentials, and use compare-and-set revisions when you refresh them, as authentication describes.
Durable stores
Neon runs several isolates in parallel and can evict any of them, so nothing the SDK needs later can live in memory. Two stores need Postgres:
- The client’s
idempotencyStorestops a retried request from posting twice.MemoryIdempotencyStoreonly covers one isolate. - The Threads adapter’s
workflowStorekeeps each post’s progress through the Threads API calls, so a later call can continue it. The defaultMemoryThreadsWorkflowStoreloses the post when the isolate goes away.
import type { Pool } from "pg";
import type { IdempotencyStore } from "@opencoredev/social-sdk";
import type { ThreadsWorkflow, ThreadsWorkflowStore } from "@opencoredev/social-sdk/threads";
export function postgresIdempotencyStore(pool: Pool): IdempotencyStore {
return {
async claim({ scope, key, fingerprint, targetKeys }) {
const inserted = await pool.query(
`insert into idempotency_claims (scope, key, fingerprint, target_keys)
values ($1, $2, $3, $4)
on conflict (scope, key) do nothing
returning claim_id`,
[scope, key, fingerprint, JSON.stringify(targetKeys)],
);
if (inserted.rows[0])
return { kind: "new", claimId: inserted.rows[0].claim_id, outcomes: {} };
const { rows } = await pool.query(
"select claim_id, fingerprint, target_keys, outcomes from idempotency_claims where scope = $1 and key = $2",
[scope, key],
);
const row = rows[0];
if (
row.fingerprint !== fingerprint ||
JSON.stringify(row.target_keys) !== JSON.stringify(targetKeys)
)
return { kind: "conflict" };
return { kind: "existing", claimId: row.claim_id, outcomes: row.outcomes };
},
async saveOutcome({ claimId, targetKey, outcome }) {
await pool.query(
"update idempotency_claims set outcomes = outcomes || jsonb_build_object($2::text, $3::jsonb) where claim_id = $1",
[claimId, targetKey, JSON.stringify(outcome)],
);
},
};
}
export function postgresThreadsWorkflowStore(pool: Pool): ThreadsWorkflowStore {
return {
async create(input) {
const workflow: ThreadsWorkflow = { ...input, id: crypto.randomUUID() };
await pool.query("insert into threads_workflows (id, workflow) values ($1, $2)", [
workflow.id,
JSON.stringify(workflow),
]);
return workflow;
},
async get(id) {
const { rows } = await pool.query("select workflow from threads_workflows where id = $1", [
id,
]);
return rows[0]?.workflow;
},
async update(id, update) {
const { rows } = await pool.query(
"update threads_workflows set workflow = workflow || $2::jsonb where id = $1 returning workflow",
[id, JSON.stringify(update)],
);
if (!rows[0]) throw new Error("Threads workflow not found");
return rows[0].workflow;
},
// A lease, not a lock, so a claim held by an evicted isolate expires on its own.
async claim(id) {
const { rowCount } = await pool.query(
`update threads_workflows set claimed_until = now() + interval '5 minutes'
where id = $1 and (claimed_until is null or claimed_until < now())`,
[id],
);
return rowCount === 1;
},
async release(id) {
await pool.query("update threads_workflows set claimed_until = null where id = $1", [id]);
},
};
}
Keep the lease longer than the retry budget you pass to resumePublication below, so a claim can’t expire while its isolate is still writing to Threads.
Publish from the function
posts.publish stores a Threads workflow, claims it, and makes the Threads API calls before it returns. The outcome is published when Threads finishes, processing when media is still being processed or another isolate holds the workflow’s claim, and unknown when a write may or may not have reached Threads. For processing, the route calls the adapter’s native resumePublication once more, so most posts finish during the request.
import { Hono } from "hono";
import { bodyLimit } from "hono/body-limit";
import { attachDatabasePool } from "@neon/functions";
import { Pool } from "pg";
import { createSocial, connectedAccountRef, type DeliveryRef } from "@opencoredev/social-sdk";
import { threads } from "@opencoredev/social-sdk/threads";
import { postgresIdempotencyStore, postgresThreadsWorkflowStore } from "./stores";
// Each isolate keeps its own pool, so total connections are max × live isolates.
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 5 });
// Without an error listener, an idle connection dropped at scale to zero crashes the isolate.
attachDatabasePool(pool);
function requiredEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`${name} is not set`);
return value;
}
const threadsUserId = requiredEnv("THREADS_USER_ID");
const social = createSocial({
backend: threads({
auth: {
userId: threadsUserId,
accessToken: requiredEnv("THREADS_ACCESS_TOKEN"),
},
workflowStore: postgresThreadsWorkflowStore(pool),
}),
idempotencyStore: postgresIdempotencyStore(pool),
});
// Native calls skip the client's tenant checks, so callers authorize the account first.
const native = social.native("default", { acknowledgeUnsafe: true })!;
async function advance(tenantId: string, deliveryId: string, ref: DeliveryRef) {
const { rows: grants } = await pool.query(
`select 1 from connected_accounts
where tenant_id = $1 and backend = $2 and platform = $3 and account_id = $4`,
[tenantId, ref.backend, ref.platform, ref.accountId],
);
if (!grants[0]) throw new Error("Account grant revoked");
const account = connectedAccountRef({
backend: ref.backend,
platform: "threads",
accountId: ref.accountId,
});
const outcome = await native.resumePublication(account, ref.deliveryId, {
backendInstance: ref.backend,
correlationId: deliveryId,
retryBudget: { maxAttempts: 3, maxElapsedMs: 60_000 },
});
await pool.query(
"update deliveries set state = $2 where id = $1 and state not in ('published', 'failed')",
[deliveryId, outcome.state],
);
return outcome;
}
function parsePublishBody(body: unknown) {
if (typeof body !== "object" || body === null) return undefined;
if (!("draftId" in body) || !("text" in body)) return undefined;
const { draftId, text } = body;
if (typeof draftId !== "string" || !/^[\w-]{1,100}$/.test(draftId)) return undefined;
// A local check in UTF-16 code units, the same count the adapter uses. Threads counts on its side.
if (typeof text !== "string" || text.length === 0 || text.length > 500) return undefined;
return { draftId, text };
}
const app = new Hono();
app.post("/publish", bodyLimit({ maxSize: 16 * 1024 }), async (c) => {
const tenant = await requireTenant(c); // your JWT or API-key check
const input = parsePublishBody(await c.req.json().catch(() => null));
if (!input) return c.text("Expected a draftId and 1 to 500 characters of text", 400);
const { draftId, text } = input;
// This client only holds credentials for THREADS_USER_ID, so only that account can publish.
const { rows } = await pool.query(
"select account_id, backend from connected_accounts where tenant_id = $1 and platform = 'threads' and account_id = $2",
[tenant.id, threadsUserId],
);
const acct = rows[0];
if (!acct) return c.text("No authorized Threads account", 403);
const result = await social.posts.publish(
{
targets: [
{
account: connectedAccountRef({
backend: acct.backend,
platform: "threads",
accountId: acct.account_id,
}),
},
],
content: { text },
idempotencyKey: draftId,
},
// Scopes the idempotency key to this tenant, so tenants can't collide on a draft ID.
{ authorization: { tenantId: tenant.id } },
);
const outcome = result.outcomes[0];
// A concurrent duplicate can report unknown with no delivery reference; it must not
// replace a row that reconciliation can still resume, or undo a final state.
const { rows: saved } = await pool.query(
`insert into deliveries (tenant_id, idempotency_key, state, delivery_ref)
values ($1, $2, $3, $4)
on conflict (tenant_id, idempotency_key) do update set
state = case
when deliveries.state in ('published', 'failed') then deliveries.state
when excluded.delivery_ref is null and deliveries.delivery_ref is not null
then deliveries.state
else excluded.state
end,
delivery_ref = coalesce(excluded.delivery_ref, deliveries.delivery_ref)
returning id, state, delivery_ref`,
[tenant.id, draftId, outcome?.state ?? "unknown", outcome?.delivery ?? null],
);
const row = saved[0];
const state =
row.state === "processing" && row.delivery_ref
? (await advance(tenant.id, row.id, row.delivery_ref)).state
: row.state;
return c.json({ status: result.status, state });
});
export default app;
A Neon Function has a public HTTPS URL that anyone can call. requireTenant has to reject the request before any database or SDK work: verify a JWT against your auth provider’s JWKS (Neon Auth injects NEON_AUTH_JWKS_URL when it is enabled on the branch) or check an API key. The public URL also means browsers can call the function directly, so a long publish like a video upload doesn’t hit your web host’s request time limit. If you do this, handle OPTIONS and set CORS headers.
Reconcile on a schedule
A delivery can still be processing after the request ends: Threads may not have finished processing the media, or Neon may have evicted the isolate partway through. The trigger declared in neon.ts sends a POST to /reconcile every ten minutes, even when the compute has scaled to zero, and the route resumes those workflows. The route is public, but Neon strips client-set X-Neon-* headers, so only trigger calls carry X-Neon-Trigger-Invocation-Id.
app.post("/reconcile", async (c) => {
if (!c.req.header("x-neon-trigger-invocation-id")) return c.text("forbidden", 403);
// The join skips deliveries whose account the tenant has since disconnected.
const { rows } = await pool.query(
`select d.id, d.tenant_id, d.delivery_ref from deliveries d
join connected_accounts a
on a.tenant_id = d.tenant_id
and a.backend = d.delivery_ref->>'backend'
and a.platform = d.delivery_ref->>'platform'
and a.account_id = d.delivery_ref->>'accountId'
where d.state = 'processing'
limit 20`,
);
for (const row of rows) await advance(row.tenant_id, row.id, row.delivery_ref);
return c.json({ resumed: rows.length });
});
Here, a connected_accounts row represents an active grant; delete it when access is revoked. advance rechecks the tenant and the reference’s exact backend, platform, and account ID before every native continuation, including the one after publishing. Native calls bypass any SDK AuthorizationPolicy.
The workflow store’s claim stops two isolates from resuming the same post at once, so an overlapping or redelivered run does no harm. resumePublication and posts.getDelivery both continue a workflow that is still processing, from the stage it reached. A workflow whose state is unknown is never replayed: both report unknown again. Leave those rows for review, as the idempotency guide describes. Pick whatever cadence suits you; the SDK never polls on its own.
To test the route locally, run neon dev and replay the trigger call:
curl -X POST http://localhost:8787/reconcile \
-H "X-Neon-Trigger-Invocation-Id: local-test" \
-H "Content-Type: application/json" \
-d '{"data":{"scheduled_at":"2026-09-22T12:00:00Z"}}'
Webhooks
This route receives Zernio webhooks, so it only applies when you publish through the Zernio managed backend. Threads sends its own webhooks to a direct integration: verify those with verifyMetaWebhook and answer the subscription check with answerMetaWebhookChallenge, as the events guide shows. The body handling below is the same for both.
Hono exposes the untouched request as c.req.raw. Read its bytes before any JSON parsing and pass its Headers object as is. The route reads the body before anything is authenticated, so bodyLimit caps it at the verifier’s 1 MiB default, including chunked requests without a Content-Length:
import { SocialError } from "@opencoredev/social-sdk";
import { verifyZernioWebhook } from "@opencoredev/social-sdk/server";
app.post(
"/events",
bodyLimit({ maxSize: 1024 * 1024, onError: (c) => c.text("payload too large", 413) }),
async (c) => {
const body = new Uint8Array(await c.req.raw.arrayBuffer());
try {
await verifyZernioWebhook({
secret: requiredEnv("ZERNIO_WEBHOOK_SECRET"),
headers: c.req.raw.headers,
body,
});
} catch (error) {
if (error instanceof SocialError && error.code === "unauthorized") {
return c.text("unauthorized", 401);
}
throw error;
}
// decode, resolve tenants, insert into events_inbox with ON CONFLICT DO NOTHING
return c.text("accepted", 202);
},
);
Keep the inbox in an events_inbox table with a unique dedupe key, following the events guide. Process pending rows from a second scheduled trigger instead of waitUntil: Neon documents waitUntil for short post-response work, and an evicted isolate drops anything still in memory.
Notes
- When Neon evicts an isolate, it sends
SIGINTand stops the process five seconds later. If that happens beforeposts.publishclaims the draft ID, nothing was sent, and the caller can retry. If it happens duringposts.publish, the claim exists without an outcome, so a retry with the same draft ID returnsunknownwithout calling Threads; review that draft before publishing again. If it happens after thedeliveriesrow is saved asprocessing, the next reconcile run resumes it. - Each branch gets its own function URL and a copy of the parent’s data, including
connected_accountsandthreads_workflows. Deploy preview branches with test credentials so a preview can’t post to production accounts. - Neon enforces a per-account limit on concurrent invocations and answers extra requests with
429and aRetry-Afterheader before your handler runs. No publish happened in that case, so the caller can retry. - Functions are available only in some Neon regions. Check the Neon Functions overview before creating the project.
- The Neon details on this page come from Neon’s documentation as of September 22, 2026: runtime limits (Node.js 24, isolates, eviction,
waitUntil, the concurrency limit), environment variables, deploy and manage (slugs), get started (pgpool,attachDatabasePool), scheduled triggers (cron,X-Neon-*headers, scale to zero), Function Triggers (local replay), and theneon.tsreference. Neon Functions change often, so check those pages before relying on a limit. - The samples have not been run against a live Neon project or Threads account.