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

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

Four canonical patterns

Pick the shape that matches your trust posture.

#ShapeWhat you verify on the webhookWhen 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

SymptomLikely cause
ownerMetadata exceeds 4096 byte cap from POST /api/connection-intentsObject 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 objectYou sent an array or a string. Wrap it: { "value": ... }.
ownerMetadata is null on the webhook envelopeThe intent was created without metadata. Edit the conversation’s metadata via the inviting flow to overwrite.

Next steps