Why this exists
Your customers are asking you to connect their tools. "Can it post to our Slack?" "Can it sync to our HubSpot?" "Can it write to a Google Sheet?" Every one of those is a reasonable request, and every one is a small project.
What one integration actually costs
Take Slack, on your own. Before a single message is sent you build:
- Slack OAuth app — registered, scoped, redirect URLs configured, kept valid as Slack changes its requirements.
- An authorisation flow — a consent redirect, a callback route, state and PKCE handling, error paths for a user who declines.
- A token store — encrypted at rest, one row per customer. You are now holding credentials that let you act as your customers inside their workspace. That is a breach surface you did not have yesterday.
- A refresh job — tokens expire. Something has to renew them on schedule and handle the ones that fail.
- An API client — Slack's own shapes, its rate limits, its pagination, its error codes. Rewritten whenever Slack changes.
- A public endpoint to receive Slack events — with signature verification, replay protection, and retry handling.
- A polling loop, for the apps that have no webhooks at all.
Then a customer asks for HubSpot. Different OAuth quirks, different token lifetime, different pagination, different event model. You build most of it again.
The real problem is the tenth app, not the first
One integration is a sprint. Ten is a permanent team. The cost is not the building — it is the maintenance that never ends, on code that is not your product.
What viaSocket does instead
viaSocket sits between your product and 2,300+ applications and gives you one contract for all of them: connect an account, list options, run an action, react to an event. Same four things whether your user picked Slack, Gmail, HubSpot or Notion.
| The job | Without viaSocket | With viaSocket |
|---|---|---|
| Getting the user's permission | You register an OAuth app per service and build the flow | One popup call. viaSocket owns the OAuth client and the consent screen. |
| Holding credentials | Your database, encrypted, your liability | viaSocket holds each grant encrypted and refreshes it. Your database never sees a token. |
| Talking to the app | An API client per app, maintained forever | One call shape. viaSocket speaks each app's API. |
| Reacting to events | A public endpoint, signatures, retries — or a polling loop | Subscribe. viaSocket polls the apps that need polling and handles de-duplication, renewal and retries. |
| Adding the next app | Most of the work again | Three ids change. Nothing else. |
This quickstart covers the Apps API: your screens, your design, your backend making the calls. The steps are identical for every app — only the ids change.
Why use it
You ship integrations in hours, not sprints
The first one takes an afternoon. The tenth takes twenty minutes, because it is the same ten steps with different ids. Your integration backlog stops being a roadmap item.
You never hold your customers' credentials
This is the part worth pausing on. If you build integrations yourself, your database fills with access tokens for other people's Slack workspaces, inboxes and CRMs. A breach is no longer your data — it is theirs, across every customer at once. With viaSocket the grant lives with viaSocket, encrypted and refreshed. Your database holds an opaque id that is useless on its own.
Your users never leave your product
They click a button in your UI, approve in the app's own consent popup, and they are back. No redirect to a third-party site, no separate account to create, no other brand in the flow.
It is your UI, not a widget
The Apps API gives you data, not screens. The connect button, the app list, the channel dropdown — you build all of it, in your own design system. Nothing in your product has to look like someone else's.
Integrations stop breaking silently
Apps change their APIs, rotate their auth, deprecate endpoints. When that happens, viaSocket absorbs it. You find out because nothing broke.
Your infrastructure gets simpler, not bigger
No public webhook endpoint. No queue. No polling workers. No refresh cron. An event subscription carries a small handler that viaSocket runs in its own sandbox when the event fires — nothing in your product has to be reachable from the internet.
One integration layer for your whole product
The same connection your user made works for your app features, your automations and your AI agent. They authorise once.
Two ways to set up
| Option | What happens | Good for |
|---|---|---|
| Set up with AI | Your dashboard generates a ready-made instruction file for your AI coding agent. The agent writes the integration code for you. | Fastest path. Available in the Apps API section of your project. |
| Set up manually | You follow the steps below yourself, in any language. | Full control, non-JavaScript stacks, or reviewing what the AI path produces. |
Both call exactly the same endpoints. This page is the manual path.
Slack is the example, and the code says so
Every sample below uses Slack, posting a message to a channel. Slack is not special and nothing in these steps is Slack-specific — swap three ids and the same ten steps append a row to Google Sheets or create a deal in HubSpot.
Each code block says this in a comment on its own first line, so a snippet you copy — into your editor, or into an AI assistant — carries that context with it.
Code is shown as cURL, Node.js and Python. Pick a tab and the whole page follows. Working in Go, PHP or Ruby? Translate from the cURL tab — it is the language-neutral form, and there is nothing viaSocket-specific about the HTTP.
1Create an embed and get your three values
Everything else on this page needs three values from your viaSocket project: an org id, a project id and a signing secret. You create them once.
- 1Sign in at viasocket.comCreate an account if you do not have one. Free to start.
- 2Go to flow.viasocket.com/integrationsPick your project, then open Configuration.
- 3Click Create new embedThis generates the signing secret for this project.
- 4Copy the org id, project id and secretAll three are shown on that screen. The secret is shown masked — reveal it to copy.
Paste them into your environment file:
VIASOCKET_ORG_ID=
VIASOCKET_PROJECT_ID=
VIASOCKET_EMBED_SECRET=The secret is not the token
Anyone holding the signing secret can act as any of your users. It stays on your server — never in your frontend bundle, never in the repo. A secret committed once survives in git history after you delete it from the file. Check .gitignore covers .env before you paste it in.
Read them from the environment, every time
The project id in particular changes — you may move projects, or run one project for the Apps API and another for an embed. Hardcoding it means editing source to change environment.
2Install the SDK
Pick your language once — the tabs below remember your choice for the whole page.
npm install viasocket-apps
# Node 20+, Bun, Deno, edge runtimes. Zero dependencies. MIT.The SDK is a convenience for Node, not a requirement. There is no Python SDK — the Python tab calls the HTTP endpoints directly, and every step below shows that raw call, so any language works.
Base URLs
- API calls go to
https://flow-api.viasocket.com - Running an action goes to
https://flow.sokt.io/func/<script_id>
3Sign an embed token
Why this step exists. viaSocket has to know two things on every call: that the request really came from your product, and which of your end users it is for. One short signed token answers both. You sign it yourself, so viaSocket never needs a list of your users and you never need an API key in the browser.
Every call below sends one header: authorization: <embed token>. It is a JWT you sign on your backend with the three values from step 1.
The payload
{
"org_id": "<your org id>",
"project_id": "<your project id>",
"unique_identifier": "<your own id for this end user>"
}unique_identifier is your own id for the end user. Connections and subscriptions are isolated per identifier, so two of your users never see each other's data. Use one identifier per user, forever — change it and the user appears to have lost their connections.
Sign it
import { ViaSocket } from 'viasocket-apps'
export const viasocket = new ViaSocket({
orgId: process.env.VIASOCKET_ORG_ID,
projectId: process.env.VIASOCKET_PROJECT_ID,
secret: process.env.VIASOCKET_EMBED_SECRET // server-side only
})
// Everything is done for one of your end users, identified by an id you choose.
const user = viasocket.user('user_1042')
// Your frontend needs this to open the connect popup.
// Sign per request; never cache it in the browser.
const embedToken = await user.token()There is nothing viaSocket-specific about the signing itself. It is a standard HS256 JWT — any library in any language produces the same token.
4Find the app and get its service_id
Every remaining step needs a service_id — viaSocket's id for the app. Fetch it from the app search. Public: no auth, no org id, no secret.
{
"success": true,
"message": "search successful",
"data": [
{ "service_id": "rowbu58rc", "name": "Slack", "description": "..." }
]
}service_id is what the connect popup takes and what every later call is built on. name is what you render.
- It is a search, not a list-all. An empty
keyreturns an empty array, so wire it to your own search box and query on input, debounced. Do not call it on mount expecting a full catalog. - It matches descriptions as well as names, so results can look loose. Pick by
name. - Not in the SDK. Plain
fetch, from your backend. - Never hardcode a
service_idand never keep your own copy of the app list. Both go stale.
// Backend route behind your own search box.
// EXAMPLE: a request of ?q=slack returns Slack's service_id.
app.get('/api/integrations/search', async (req, res) => {
const term = req.query.q
if (!term) return res.json([]) // an empty key returns nothing
const r = await fetch(
`https://flow.sokt.io/func/scri12BSufQM?key=${encodeURIComponent(term)}`
)
const { data } = await r.json()
// Send only what your UI needs.
res.json(data.map(a => ({ id: a.service_id, name: a.name, icon: a.iconurl })))
})Wire that to a search box in your product. The user types, picks an app, and you now have its service_id for the next step.
5Connect the app — your user authorises it
Why this step exists. viaSocket cannot touch your user's account until that user says yes. This step opens the app's own sign-in and consent screen in a popup. Your user approves there, on the app's real domain, and viaSocket receives and stores the grant. Your code never sees a password or a token.
This is the only step that runs in the browser.
Your frontend — JavaScript only; this is the one step that runs in the browser
// EXAMPLE: connecting Slack. serviceId comes from step 4 — pass any app's id.
import { connect } from 'viasocket-apps/browser'
async function onConnectClick(serviceId) { // e.g. Slack's 'rowbu58rc'
// 1. Ask your backend for a fresh token (step 3).
const embedToken = await fetch('/api/integrations/token').then(r => r.text())
try {
// 2. Popup opens. This waits until the user approves or closes it.
const { authId } = await connect({ embedToken, serviceId })
// 3. Hand the authId to your backend and save it.
await fetch('/api/integrations/connected', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ serviceId, authId })
})
showConnected()
} catch (error) {
if (error.code === 'closed') return // user closed the popup — nothing happened
showError(error.message)
}
}Your backend — plain storage, nothing viaSocket-specific
// Receives the authId the popup produced. Nothing app-specific here.
app.post('/api/integrations/connected', async (req, res) => {
const { serviceId, authId } = req.body
await db.connections.insert({
userId: req.session.userId, // your user
serviceId, // which app — e.g. Slack's 'rowbu58rc'
authId // viaSocket's id for this connection
})
res.json({ ok: true })
})connect() rejects with a code you can branch on:
| code | Meaning | What to do |
|---|---|---|
| 'closed' | The user closed the popup | Nothing. No connection was created. Do not show an error. |
| 'rejected' | The app refused the authorisation | Show the message and let them retry. |
| 'script' | The connect script did not load | Network or content-blocker problem on the user's side. |
Store this
Save the auth_id against your own user id and the service_id. That is the whole record. No tokens, no keys, no app credentials.
Listing and disconnecting
Two methods cover the rest of a connection's life. Build a settings screen with them — connected apps listed, each with a Disconnect button.
// Node.js. Works for every connected app, not just the example one.
const connections = await user.listConnections() // what has this user connected?
await user.revokeConnection(authId) // disconnect one
await db.connections.remove({ userId: req.session.userId, authId })After revoking, that auth_id and the script_id built from it both stop working. The user has to connect again from scratch.
6Enable the app — only if you will run actions
Why this step exists. A connection proves your user said yes. It does not yet give you anything to call. Enabling turns that connection into a runnable endpoint — the script_id — so running an action later is a single POST with no lookup and no token exchange.
Do it once per connection and store the result next to the auth_id.
// Run this right after you save the authId in step 5.
// EXAMPLE: serviceId is Slack's 'rowbu58rc'. Any app's id works the same.
const user = viasocket.user(req.session.userId)
// findEnabled first, so one connection never ends up with two script_ids.
const scriptId =
(await user.findEnabled(serviceId)) ??
(await user.enable(serviceId, authId))
await db.connections.update(
{ userId: req.session.userId, serviceId },
{ scriptId }
)From here on, that script_id is what you call to make the app do things. You will not need the auth_id again except for reading values in step 9 and disconnecting.
Skip this if you only need events
Only actions need enabling. Subscribing to a trigger takes the auth_id and nothing else — if your integration just listens for events, go straight to the trigger you want.
7Choose an action or trigger
Why this step exists. "Slack" is not a thing you can run. Send Message is. Every app publishes a list of things it can do — actions, which your code makes happen, and triggers, which happen in the app and tell your code about it. Slack publishes 25 actions and 6 triggers. Each one has its own id, and you need the id of the one you want.
You pick this while you are writing the code, not at runtime. Your feature already knows what it does.
Option A — find it in the dashboard
The dashboard is the viaSocket web app where you manage your projects. You were already in it in step 1, on the Configuration screen.
- 1Go to flow.viasocket.com/integrationsSign in if you are not already.
- 2Open your projectThe same one whose keys you copied in step 1.
- 3Open Apps & API Reference in the left menuUnder Get Started.
- 4Search for your appType "slack" and select it.
- 5Read the list of actions and triggersEach row shows its type, its name, and its id on the right — for example rowj2u3wc8h5 for Slack's Send Message. Copy that id.
Below the list, the same screen shows the fields that action takes. That is what step 8 is about.
Option B — fetch it as a document
Same information, as an API call, using the service_id from step 4. Public, no auth. Useful if you want it in your editor rather than a browser tab.
# EXAMPLE: rowbu58rc is Slack's service_id, from step 4.
curl -H "Accept: text/markdown" \
"https://beta-flow.viasocket.com/documentation/rowbu58rc?format=http"format=httpgives language-neutral instructions.format=sdkgives theviasocket-appsversions.- It lists every action and trigger with its id, every field with its type and dependencies, sample
inputData, and a troubleshooting table. - A 404 means that app has no published actions or triggers.
- It is generated from the live catalog, so refetch it rather than trusting a saved copy.
Read it while you build, not at runtime
This document is reference material for you as you write the code. Your product does not download it for each end user.
8Build inputData
What inputData is. It is the JSON body you send when you run the action — the answers to everything the action needs to know. For "send a message" that means: which channel, what text, send now or later. Every action defines its own set of keys.
The reference screen from step 7 lists those keys in a table. Each row tells you the key's name, its type, and where its value comes from. There are three cases, and telling them apart is the whole job of this step.
Case 1 — you provide the value
Plain values your code or your user already has. Text, a number, true or false. Nothing to fetch.
// Slack example — "messageto" is a Slack key, not a viaSocket one.
{ "messageto": "channel" }Case 2 — the value has to be fetched from the app
The reference marks these with a list-options badge. They hold ids that only the app knows — a channel id, a spreadsheet id, a pipeline id. You cannot type these and you cannot guess them. Step 9 fetches them.
// Slack example — a channel id. Your app will have its own equivalent.
{ "channel_id": ["C01ABCD2EFG"] } // ← this id came from a list-options callCase 3 — the key only applies sometimes
Some keys are alternatives to each other. The reference shows the rule in orange, like only when messageto = "channel".
These are not optional extras. They are branches. Send the ones whose condition is true and leave the others out of the JSON entirely — sending two alternatives together is an error, not a fallback.
// messageto is "channel", so send channel_id and omit userId completely.
{ "messageto": "channel", "channel_id": ["C01ABCD2EFG"] }
// If messageto were "user", it would be the other way round.
{ "messageto": "user", "userId": ["U04XYZ9PQRS"] }Dotted keys mean nesting
The reference writes nested keys with a dot. A key shown as destination.channel_id is not a key called "destination.channel_id" — it is channel_id inside an object called destination.
// Reference shows: destination.messageto
// destination.channel_id
// You send:
{
"destination": {
"messageto": "channel",
"channel_id": ["C01ABCD2EFG"]
}
}This matters twice: here, and again in step 9, where the same dotted path is how you name the field you are fetching values for.
Putting it together
Go down the reference table once and sort every key into one of three piles:
| Pile | What you do |
|---|---|
| Required, you provide | Fill it in now. |
| Required, list-options | Note it. Step 9 fetches it. |
| Conditional | Decide the branch your feature uses, then treat its keys as one of the two piles above. Ignore the other branch. |
Anything left over is optional. Leave it out until you need it.
9Get the values that only the app knows
In step 8 you sorted the keys. This step fills in the ones marked list-options.
Why this step exists. Slack does not accept #general. It accepts C01ABCD2EFG. That id exists only inside your user's Slack workspace — your code has no way to know it, and neither does your user. So you ask viaSocket for the real list, show your user the readable names, and send back the id they chose.
It is the same call for every such key. One call per key.
// EXAMPLE: ACTION_ID is Slack's Send Message, the field is its channel list.
// Swap both for your own app's values from step 7.
app.get('/api/integrations/options', async (req, res) => {
const { authId } = await db.connections.find({
userId: req.session.userId,
serviceId: SERVICE_ID
})
const user = viasocket.user(req.session.userId)
const { options } = await user.listOptions(ACTION_ID, {
fieldKey: req.query.field, // e.g. 'destination.channel_id'
authId,
existingFields: {}
})
res.json(options)
})What comes back — Slack channels, in this example
{
"options": [
{ "label": "#general", "value": "C01ABCD2EFG" },
{ "label": "#engineering", "value": "C05HIJK6LMN" }
]
}Show the label. Send the value. The value is what goes into inputData in step 10.
The three inputs
| Input | What to send |
|---|---|
fieldKey | The key's full dotted path, exactly as the reference writes it — destination.channel_id, not channel_id. |
authId | The connection from step 5. This is how viaSocket knows whose channels to list. |
existingFields | Usually {}. Used when this field depends on an earlier one — see below. |
When one value depends on another
Some values cannot be listed until an earlier choice is made. Slack cannot list the messages in a thread until it knows which channel. The reference marks these — needs destination.thread_channel_id.
Fetch the first one, then pass the chosen value into the second call inside existingFields, nested exactly the way inputData nests it:
// 1. Get the channels.
const channels = await user.listOptions(ACTION_ID, {
fieldKey: 'destination.thread_channel_id',
authId,
existingFields: {}
})
// User picks one → 'C01ABCD2EFG'
// 2. Now the messages in that channel become listable.
const messages = await user.listOptions(ACTION_ID, {
fieldKey: 'destination.thread_ts',
authId,
existingFields: {
destination: { thread_channel_id: 'C01ABCD2EFG' }
}
})When the list is long
A workspace with hundreds of channels should not load all of them. Pass what your user typed as _searchText and the app filters server-side:
existingFields: { _searchText: 'eng' }Empty list?
Three usual causes: the app is not connected for that unique_identifier; the fieldKey is missing its parent path; or the field depends on an earlier value that is not in existingFields.
10Run the action
This is the call that actually makes something happen in your user's app. Everything before it was setup.
What you send
| Part | Where it came from |
|---|---|
script_id — in the URL | Step 6, when you enabled the app. Identifies which connection to run against. |
action_id | Step 7, from the reference. Identifies what to do. |
inputData | Step 8, with the fetched values from step 9 filled in. The details. |
There is no authorization header on this call. The script_id in the URL is itself the credential.
// EXAMPLE: posting to a Slack channel. The inputData keys below are Slack's —
// your app's keys come from its own reference (step 7).
app.post('/api/integrations/run', async (req, res) => {
const { scriptId } = await db.connections.find({
userId: req.session.userId,
serviceId: SERVICE_ID
})
try {
const result = await viasocket.runAction(scriptId, ACTION_ID, {
destination: {
messageto: 'channel',
channel_id: [req.body.channelId] // the value your user picked in step 9
}
})
res.json(result)
} catch (error) {
// ViaSocketError carries the app's own message, status and body.
console.error(error.status, error.message, error.body)
res.status(502).json({ error: error.message })
}
})What comes back
The app's own response, passed straight through. Slack returns Slack's JSON, HubSpot returns HubSpot's. Check it and store whatever your product needs — a message id, a record id — so you can reference it later.
Treat script_id like a password
Anyone holding it can run that app as that user, with no token needed. Keep it in your database, server-side. It must never reach the browser, a log line, or an error you return to the client.