@medalsocial/sdk
v1.3.0
Published
TypeScript SDK for Medal Social API — posts, emails, contacts, deals, helpdesk, webhooks, and GDPR compliance
Downloads
6,452
Readme
Medal Social SDK
TypeScript SDK for the Medal Social API. Manage posts, emails, contacts, deals, helpdesk conversations, webhooks, and GDPR compliance programmatically.
Install
npm install @medalsocial/sdk
# or
pnpm add @medalsocial/sdkQuick Start
import { Medal } from '@medalsocial/sdk';
const medal = new Medal('medal_xxx');
// Create and schedule a social post
const { data: post } = await medal.posts.create({
content: 'Hello from the Medal Social SDK!',
channel_ids: ['ch_1'],
});
await medal.posts.schedule(post.id, { scheduled_at: '2026-03-15T10:00:00Z' });
// Send a transactional email
await medal.emails.send({
template_slug: 'welcome',
to: '[email protected]',
variables: { name: 'John' },
});
// Manage contacts
const { data: contactRef } = await medal.contacts.create({
email: '[email protected]',
first_name: 'John',
status: 'lead',
});
const { data: contact } = await medal.contacts.get(contactRef.id);Authentication
Two authentication methods are supported:
API Key (recommended for server-side)
Create an API key in your workspace settings. Keys are prefixed with medal_ and scoped to a single workspace.
const medal = new Medal('medal_xxx');
// With options
const medal = new Medal('medal_xxx', {
baseUrl: 'https://io.medalsocial.com', // default
timeout: 30000, // default, in ms
});OAuth Access Token
For OAuth integrations, pass the access token and the target workspace ID:
const medal = new Medal('oauth_access_token', {
workspaceId: 'workspace_id', // required for OAuth
});OAuth tokens are obtained through the Medal Social OAuth flow (/api/auth/oauth2/authorize). The workspaceId is required because OAuth tokens can access multiple workspaces.
Resources
Posts
// List connected channels
const { data: channels } = await medal.posts.channels();
// Create a post
const { data } = await medal.posts.create({
type: 'social', // 'social' | 'newsletter' | 'blog'
content: 'Hello!',
channel_ids: ['ch_1'],
});
// Get post with per-channel variants
const { data: post } = await medal.posts.get(data.id);
console.log(post.variants); // platform-specific status, permalinks
// Update a draft
await medal.posts.update(data.id, { content: 'Updated!' });
// Schedule or publish
await medal.posts.schedule(data.id, { scheduled_at: '2026-03-15T10:00:00Z' });
await medal.posts.publish(data.id);
// List posts
const posts = await medal.posts.list({ status: 'draft', type: 'social', limit: 50 });
// Delete
await medal.posts.remove(data.id);Emails
// Send transactional email
const { data: sent } = await medal.emails.send({
template_slug: 'welcome',
to: '[email protected]',
name: 'John',
locale: 'en',
variables: { company: 'Acme' },
contact_id: 'c_123', // optional: link to a contact
});
// Check delivery status
const { data: status } = await medal.emails.get(sent.id);
console.log(status.status); // 'queued' | 'sent' | 'delivered' | 'opened' | 'clicked'
// Batch send (max 100 recipients)
const { data: batch } = await medal.emails.batch({
template_slug: 'newsletter',
default_locale: 'en',
recipients: [
{ email: '[email protected]', name: 'Alice', variables: { code: 'A1' } },
{ email: '[email protected]', name: 'Bob' },
],
});
console.log(batch.batch_id, batch.total, batch.queued, batch.failed);
// Templates
const { data: templates } = await medal.emails.templates.list();
const { data: template } = await medal.emails.templates.get('welcome', {
locale: 'ar',
fallback_locale: 'en',
});Contacts
// CRUD
const { data: created } = await medal.contacts.create({
email: '[email protected]',
first_name: 'John',
last_name: 'Doe',
company: 'Acme',
job_title: 'CTO',
status: 'lead',
label_ids: ['lbl_1'],
custom_fields: { source: 'website' },
});
const { data: contact } = await medal.contacts.get(created.id);
const { data: updated } = await medal.contacts.update(created.id, { status: 'customer' });
const { data: removed } = await medal.contacts.remove(created.id);
console.log(updated.success, removed.success);
// List with filters
const contacts = await medal.contacts.list({
status: 'lead',
email_status: 'subscribed',
label_ids: ['lbl_1'],
search: 'john',
limit: 50,
});
// Activity timeline
const activities = await medal.contacts.activities('contact_id', { limit: 20 });
// Add a note
const { data: note } = await medal.contacts.addNote('contact_id', { content: 'Follow up next week' });
console.log(note.id);
// Bulk import (max 500)
const { data: result } = await medal.contacts.import([
{ email: '[email protected]', first_name: 'Alice' },
{ email: '[email protected]', first_name: 'Bob' },
]);
console.log(result.added, result.skipped);Deals
const { data: created } = await medal.deals.create({
title: 'Enterprise Partnership',
value: 50000,
currency: 'USD',
brand_name: 'Acme Corp',
contact_id: 'c_123',
notes: 'Initial outreach',
});
const { data: deal } = await medal.deals.get(created.id);
const { data: updated } = await medal.deals.update(deal.id, { status: 'won' });
const { data: unlinked } = await medal.deals.update(deal.id, { contact_id: null }); // unlink contact
const deals = await medal.deals.list({ status: 'open', search: 'Acme' });
const { data: removed } = await medal.deals.remove(deal.id);
console.log(updated.success, unlinked.success, removed.success);GDPR
// Consent management
await medal.gdpr.recordConsent({
email: '[email protected]',
consent_type: 'marketing_email', // | 'analytics_tracking' | 'third_party_sharing'
granted: true,
source: 'signup_form',
});
const { data: consents } = await medal.gdpr.getConsent('[email protected]');
// Data exports
const { data: exp } = await medal.gdpr.requestExport();
const { data: exports } = await medal.gdpr.listExports();
const { data: status } = await medal.gdpr.getExport(exp.request_id);
console.log(status.download_url); // available when status is 'completed'
// Cookie consent (website integration)
await medal.gdpr.cookieConsent({
domain: 'example.com',
consentStatus: 'granted',
consentTimestamp: new Date().toISOString(),
cookiePreferences: {
necessary: { allowed: true },
analytics: { allowed: true },
marketing: { allowed: false },
},
});Helpdesk
// List/search conversations
const conversations = await medal.helpdesk.conversations.list({
status: 'open', // 'open' | 'snoozed' | 'closed'
assignee_user_id: 'user_1',
requester: '[email protected]', // match visitor name/email
query: 'refund', // free-text search
channels: ['widget', 'whatsapp'], // channel filter
limit: 50,
});
// Read one conversation + its messages
const { data: conversation } = await medal.helpdesk.conversations.get('conv_id');
const messages = await medal.helpdesk.conversations.messages('conv_id', { limit: 50 });
// Assign / snooze / close
await medal.helpdesk.conversations.update('conv_id', { assignee_user_id: 'user_1' });
await medal.helpdesk.conversations.update('conv_id', { status: 'closed', assignee_user_id: null });
// Reply as an operator (see "Helpdesk bridge" below for idempotency)
const { data: reply } = await medal.helpdesk.replies.create(
{
conversation_id: 'conv_id',
body: 'Thanks for reaching out — on it!',
author_name: 'Support Bot', // optional display name
message_type: 'chat', // or 'note' for an internal note
},
{ idempotencyKey: crypto.randomUUID() },
);Webhooks
// Create an endpoint. The signing secret is returned EXACTLY ONCE — store it
// securely immediately; you cannot retrieve it again.
const { data: endpoint } = await medal.webhooks.create(
{
name: 'Helpdesk bridge',
url: 'https://example.com/medal/webhook', // must be https
event_types: ['helpdesk.message_received', 'helpdesk.conversation_status_changed'],
channels: ['widget'], // optional channel filter
},
{ idempotencyKey: crypto.randomUUID() },
);
console.log(endpoint.secret); // whsec_… — shown only in this response
// Manage endpoints
const { data: endpoints } = await medal.webhooks.list();
const { data: one } = await medal.webhooks.get(endpoint.id);
await medal.webhooks.update(endpoint.id, { enabled: false });
await medal.webhooks.delete(endpoint.id);
// Observe deliveries + send a signed test event
const { data: deliveries } = await medal.webhooks.deliveries(endpoint.id, { limit: 20 });
await medal.webhooks.test(endpoint.id); // queues a 'test.ping' deliveryFailed deliveries retry with exponential backoff (up to 6 attempts) before being dead-lettered.
Workspaces
const { data: workspaces } = await medal.workspaces.list();
console.log(workspaces); // [{ id, name, slug }]Helpdesk bridge
Build a two-way bridge: receive helpdesk events on a webhook, and reply through the API.
Every delivery is signed. The X-Medal-Signature header carries sha256=<base64(HMAC-SHA256("{timestamp}.{rawBody}", secret))>, where timestamp is the X-Medal-Timestamp header (Unix ms). Use verifyWebhookSignature to authenticate the delivery and get a fully typed event back — it recomputes the HMAC with Web Crypto (works in Node.js 18+, Deno, Bun, Cloudflare Workers) and rejects stale timestamps (default tolerance 5 minutes).
import { Medal, verifyWebhookSignature, WebhookVerificationError } from '@medalsocial/sdk';
const medal = new Medal(process.env.MEDAL_API_KEY);
// Example: a fetch-style handler (Cloudflare Workers, Hono, Next.js route, …).
// IMPORTANT: verify against the RAW body string — do not JSON.parse first.
export async function handleWebhook(request: Request): Promise<Response> {
const payload = await request.text();
let event;
try {
event = await verifyWebhookSignature({
payload,
timestamp: request.headers.get('X-Medal-Timestamp') ?? '',
signature: request.headers.get('X-Medal-Signature') ?? '',
secret: process.env.MEDAL_WEBHOOK_SECRET, // the whsec_… from webhooks.create
});
} catch (err) {
if (err instanceof WebhookVerificationError) {
return new Response(`Invalid webhook: ${err.code}`, { status: 401 });
}
throw err;
}
switch (event.type) {
case 'helpdesk.message_received': {
const { conversation, message } = event.data;
// Reply with an idempotency key so retried deliveries never double-post.
// The X-Medal-Delivery-Id header (== event.id) is a perfect key.
await medal.helpdesk.replies.create(
{
conversation_id: conversation.id,
body: `Thanks! We received: "${message.body}"`,
author_name: 'Bridge Bot',
},
{ idempotencyKey: `reply:${event.id}` },
);
break;
}
case 'helpdesk.conversation_status_changed':
console.log(event.data.previousStatus, '→', event.data.status);
break;
case 'helpdesk.conversation_assigned':
console.log('assigned to', event.data.assigneeUserId);
break;
case 'test.ping':
break; // sent by medal.webhooks.test()
}
return new Response('ok', { status: 200 }); // 2xx acknowledges the delivery
}Event types: helpdesk.conversation_created, helpdesk.conversation_assigned, helpdesk.conversation_status_changed, helpdesk.message_received, helpdesk.message_sent, helpdesk.message_delivery_updated, and test.ping. All are discriminated on event.type — TypeScript narrows event.data automatically in a switch.
Notes:
- Idempotency: deliveries are retried on failure, so make your handler idempotent. Deduplicate on
event.id(also sent as theX-Medal-Delivery-IdandIdempotency-Keyrequest headers). When replying viamedal.helpdesk.replies.create, always pass anidempotencyKey— it is required for capability-scoped tokens. - Respond fast: return a 2xx within 10 seconds; do slow work asynchronously.
- Secret handling: the endpoint secret is returned only by
webhooks.create. If lost, delete the endpoint and create a new one.
Error Handling
All API errors throw MedalApiError with structured error details:
import { Medal, MedalApiError } from '@medalsocial/sdk';
try {
await medal.contacts.get('bad_id');
} catch (err) {
if (err instanceof MedalApiError) {
console.log(err.status); // 404
console.log(err.code); // 'NOT_FOUND'
console.log(err.message); // 'Contact not found'
console.log(err.details); // field-level validation errors (if any)
}
}Retries
The SDK automatically retries on 429 (rate limited) and 5xx errors, up to 3 attempts with linear backoff. The Retry-After header is respected when present.
Rate Limits
| Endpoint | Rate | Burst | |----------|------|-------| | Read (GET) | 300/min | 100 | | Write (POST/PATCH/DELETE) | 60/min | 30 | | Email send | 100/min | 50 | | Email batch | 10/min | 5 | | Contact import | 5/min | 3 | | GDPR export | 5/hour | 2 |
Pagination
List endpoints use cursor-based pagination:
let cursor: string | undefined;
do {
const page = await medal.contacts.list({ limit: 100, cursor });
console.log(page.data);
cursor = page.pagination.next_cursor ?? undefined;
} while (cursor);OpenAPI 3.1
The SDK publishes a validated OpenAPI 3.1 contract for the API surface covered by the typed client.
import type { OpenApiComponents, OpenApiOperations, OpenApiPaths } from '@medalsocial/sdk';
import type { paths } from '@medalsocial/sdk/openapi-types';Package artifacts:
@medalsocial/sdk/openapi.yaml- source OpenAPI 3.1 YAML document@medalsocial/sdk/openapi.json- bundled JSON document generated during build@medalsocial/sdk/openapi-types- generated TypeScript contract types
Validate the contract locally:
pnpm openapi:checkRuntime Support
Node.js 18+ and modern browsers. Uses native fetch — no polyfills required.
License
Apache-2.0
