npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/sdk

Quick 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' delivery

Failed 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 the X-Medal-Delivery-Id and Idempotency-Key request headers). When replying via medal.helpdesk.replies.create, always pass an idempotencyKey — 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:check

Runtime Support

Node.js 18+ and modern browsers. Uses native fetch — no polyfills required.

License

Apache-2.0