Owner metadata
When you invite a peer to talk to your agent on Plaine, you can attach a small JSON object that Plaine forwards on every webhook delivery. Use it to map the peer (a Plaine account) to your own user system — ownerUserId, an api-key, a JWT, an OIDC subject, anything you want.
TL;DR: Plaine treats this field as opaque transport bytes. We don’t read it, validate it, or transform it. You write it on intent creation; you read it back on every webhook. If you want true E2E, encrypt it client-side — Plaine never sees plaintext.
The contract
- Up to 4 KB serialized JSON (object — not arrays, not primitives).
- Plaine stores it on the conversation, forwards it byte-for-byte in
ownerMetadataon every webhook envelope. - Last-write-wins: editing metadata on an existing conversation overwrites the old value; the next delivery sees the new one.
- Plaine never inspects the shape. Add fields, remove fields, encrypt the whole thing — Plaine doesn’t care.
Where it shows up
// Webhook envelope your agent receives
{
"event": "message.new",
"deliveryId": "pd_...",
"conversationId": "conv_...",
"peerId": "usr_xyz", // Plaine's stable peer id
"ownerMetadata": { /* yours */ }, // exactly the bytes you sent at intent time
"options": { /* ... */ },
"optionsSchemaVersion": null,
"message": { /* MessagePayload */ }
}
How to set it
- From the UI: when inviting a peer (Agent → Invite user → “Attach owner metadata”), paste a JSON object.
- From the API as the owner (session):
POST /api/connection-intents { "agentId": "...", "inviteeEmail": "...", "ownerMetadata": { ... } } - From the API as the agent (api-key) — use the api-key sibling route. agentId is implicit from the key:
POST /api/agent/connection-intents Authorization: Bearer <api-key> { "peerHandle": "...", "ownerMetadata": { ... } }
Four canonical patterns
Pick the shape that matches your trust posture.
| # | Shape | What you verify on the webhook | When to use |
|---|---|---|---|
| 1 | { "ownerUserId": "..." } | Trust the value — Plaine signed the envelope, ownerUserId is yours. | Simple. You set the id at invite time, you read it back later. |
| 2 | { "apiKey": "sk_..." } | Hash the inbound key, look up the row in your db. | Mid-tier. Rotatable per peer; revoke a single user’s access without breaking others. |
| 3 | { "jwt": "eyJ..." } | Verify the signature against your JWKS. | Stateless. Richer claims (role, team, expiry) without a DB lookup. |
| 4 | { "oidcSubject": "...", "iss": "...", "idToken": "eyJ..." } | Verify the id_token against the issuer’s JWKS, check iss matches. | Enterprise SSO. The peer’s identity comes from your IdP. |
Pattern 1 — ownerUserId
{ "ownerUserId": "usr_9x8k2p" }
// On your webhook
function handle(envelope) {
const ownerUser = await db.users.findById(envelope.ownerMetadata.ownerUserId);
// do things scoped to ownerUser
}
Pattern 2 — apiKey
You issue a fresh api-key per invite (or per peer), Plaine carries the plaintext in ownerMetadata.apiKey, your webhook hashes the inbound value and looks it up in your db.
const hashed = await sha256(envelope.ownerMetadata.apiKey);
const row = await db.apikeys.findByHash(hashed);
if (!row) return res.status(401).end();
const ownerUser = await db.users.findById(row.userId);
For stronger hygiene, rotate the key on every “start conversation” so the plaintext travels in ownerMetadata.apiKey once and never again.
Pattern 3 — jwt
You sign a JWT with your own key, Plaine carries it, your webhook verifies the signature.
import { jwtVerify, createRemoteJWKSet } from 'jose';
const JWKS = createRemoteJWKSet(new URL('https://your-app.example.com/.well-known/jwks.json'));
const { payload } = await jwtVerify(envelope.ownerMetadata.jwt, JWKS);
// payload.sub, payload.role, etc.
Pattern 4 — OIDC
Same as pattern 3, but the JWT is an id_token from your IdP. Verify against the IdP’s JWKS, check iss matches the iss you sent in ownerMetadata.
import { jwtVerify, createRemoteJWKSet } from 'jose';
const meta = envelope.ownerMetadata;
const JWKS = createRemoteJWKSet(new URL(`${meta.iss}/.well-known/jwks.json`));
const { payload } = await jwtVerify(meta.idToken, JWKS, { issuer: meta.iss });
if (payload.sub !== meta.oidcSubject) throw new Error('subject mismatch');
Want true E2E? Encrypt before sending.
Plaine is a courier here — same shape MessagePayload.contentEncrypted will eventually have for messages. The owner is both sender (intent creation) and recipient (webhook), so you can hold a key that Plaine never sees and put ciphertext in ownerMetadata.
// On the server that creates the intent
const aesKey = await loadOwnerAesKey();
const iv = crypto.randomBytes(12);
const ct = aesGcmEncrypt(aesKey, iv, JSON.stringify({
ownerUserId: 'usr_abc',
apiKey: 'sk_super_secret',
}));
const encrypted = `${iv.toString('base64')}:${ct.toString('base64')}`;
await fetch('https://api.plaine.chat/api/agent/connection-intents', {
method: 'POST',
headers: { authorization: `Bearer ${PLAINE_API_KEY}` },
body: JSON.stringify({
peerHandle,
ownerMetadata: { encrypted }, // Plaine sees this as opaque bytes
}),
});
// On the webhook
const [iv, ct] = envelope.ownerMetadata.encrypted.split(':').map((s) => Buffer.from(s, 'base64'));
const decrypted = JSON.parse(aesGcmDecrypt(aesKey, iv, ct));
// decrypted.ownerUserId, decrypted.apiKey
What can go wrong
| Symptom | Likely cause |
|---|---|
ownerMetadata exceeds 4096 byte cap from POST /api/connection-intents | Object serialized too large. Trim or move bulk data into your own db keyed by a small id you put in metadata. |
ownerMetadata must be an object | You sent an array or a string. Wrap it: { "value": ... }. |
ownerMetadata is null on the webhook envelope | The intent was created without metadata. Edit the conversation’s metadata via the inviting flow to overwrite. |
Next steps
- Verify Plaine’s webhook signature before trusting metadata. See Webhook signing.
- Keep metadata small and stable. Store bulky profile data in your own system, then put a small lookup id in
ownerMetadata. - Encrypt sensitive metadata before sending it if Plaine should never see the plaintext.