Webhooks

Receive real-time notifications from Jump EHR via webhooks. Configure endpoints and handle events.

Webhooks allow you to receive real-time notifications when events occur in Jump EHR. Instead of polling the API for changes, webhooks push data to your server as events happen.

Overview

When you configure a webhook endpoint, Jump EHR will send HTTP POST requests to your URL whenever subscribed events occur. This enables real-time integrations without consuming your API rate limit.

Event Types

Appointment Events

EventDescription
patient.createdA new patient record has been created
patient.updatedPatient demographics have changed
patient.archivedA patient has been archived

Patient webhook payloads include core demographics only. Use GET /patients/{id} to fetch the full record after receiving the notification.

Appointment Events

EventDescription
appointment.createdA new appointment has been booked
appointment.updatedAppointment details have changed
appointment.cancelledAn appointment has been cancelled
appointment.rescheduledAppointment time or date changed
appointment.completedAppointment status changed to completed

Questionnaire Response Events

EventDescription
questionnaire_response.createdA questionnaire response has been submitted
questionnaire_response.updatedA questionnaire response has been updated

Questionnaire response webhook payloads do not include the responses field (clinical data protection). Use GET /questionnaire-responses/{id} to fetch the full response after receiving the notification.

Payload Structure

All webhook payloads follow a consistent structure:

{
  "id": "evt_abc123",
  "type": "appointment.created",
  "created_at": "2025-01-15T10:30:00Z",
  "organization_id": "org_xyz789",
  "data": {
    "object": {
      "id": "apt_def456",
      "patient_id": "pat_ghi789",
      "clinician_profile_id": "cli_jkl012",
      "appointment_type_id": "type_mno345",
      "title": "Follow-up Consultation",
      "start_time": "2025-01-20T14:00:00+00:00",
      "end_time": "2025-01-20T14:30:00+00:00",
      "timezone": "Europe/London",
      "status": "confirmed",
      "location_id": "loc_pqr678",
      "is_remote": false,
      "attendee_name": "Sarah Johnson",
      "attendee_email": "sarah@example.com",
      "created_at": "2025-01-15T10:30:00Z",
      "updated_at": "2025-01-15T10:30:00Z",
      "is_untimed": false,
      "list_position": null,
      "patient_time_display": null,
      "arrival_time": null
    },
    "previous": null
  }
}

Times

An appointment's start_time and end_time (in data.object and data.previous) are written in the practice's local time with its UTC offset, and data.object.timezone names the practice's time zone. In January that is +00:00; during British Summer Time the same 14:00 appointment is 2025-07-20T14:00:00+01:00. Parse them as instants. Event and audit timestamps (created_at, updated_at) stay in UTC.

Untimed bookings

Some practices run untimed lists, such as a call-back list or a theatre list. A booking on one holds a place on the list rather than a time. For these bookings start_time and end_time are the list's window (for example 10:00 to 14:00), not an appointment time. The patient is told the window or an arrival time, never a precise start, so your system should not show start_time as the time of the appointment.

Every appointment payload carries these four fields:

FieldTypeDescription
is_untimedbooleantrue for a place on an untimed list. false for every timed appointment.
list_positioninteger or nullThe booking's 1-based place on the list. It changes when the practice reorders the list, or a booking ahead of it leaves. null for a timed appointment, and for a cancelled untimed booking.
patient_time_display"window", "arrival" or nullHow the patient was told the time: window means the whole start_time to end_time range, arrival means arrival_time. null for a timed appointment.
arrival_timestring or nullThe arrival time the patient was given, as HH:MM in the practice's local time on the day of the window. It can be earlier than start_time. Set only when patient_time_display is arrival.

On a timed appointment the last three are always present as null rather than left out, so every appointment payload has the same shape. The start_time and end_time of a timed appointment are unchanged.

An untimed booking on an arrival list looks like this:

{
  "id": "evt_abc124",
  "type": "appointment.created",
  "created_at": "2025-01-15T10:30:00Z",
  "organization_id": "org_xyz789",
  "data": {
    "object": {
      "id": "apt_def457",
      "title": "Theatre list",
      "start_time": "2025-01-20T10:00:00+00:00",
      "end_time": "2025-01-20T14:00:00+00:00",
      "timezone": "Europe/London",
      "status": "confirmed",
      "is_untimed": true,
      "list_position": 3,
      "patient_time_display": "arrival",
      "arrival_time": "09:45"
    },
    "previous": null
  }
}

(Other fields are left out of this example for brevity; the payload carries them all.) The same four fields are returned by the appointments API.

Questionnaire Response Payload Example

{
  "id": "evt_xyz789",
  "type": "questionnaire_response.created",
  "created_at": "2025-01-15T11:00:00Z",
  "organization_id": "org_xyz789",
  "data": {
    "object": {
      "id": "qr_abc123",
      "questionnaire_id": "qt_def456",
      "questionnaire_title": "New Patient Intake Form",
      "patient_id": "pat_ghi789",
      "appointment_id": null,
      "episode_id": "ep_jkl012",
      "submitted_at": "2025-01-15T11:00:00Z",
      "submitted_by_patient": true,
      "is_pre_booking": false,
      "created_at": "2025-01-15T11:00:00Z",
      "updated_at": "2025-01-15T11:00:00Z"
    },
    "previous": null
  }
}

The responses field (containing actual form answers) is intentionally excluded from webhook payloads for clinical data protection. Fetch the full response via the API after receiving the notification.

Payload Fields

FieldTypeDescription
idstringUnique identifier for this event
typestringThe event type (e.g., appointment.created)
created_atstringISO 8601 timestamp when event occurred
organization_idstringYour organization ID
data.objectobjectCurrent state of the resource
data.previousobjectPrevious state (for update events only)

Setting Up Webhooks

1. Create a Webhook Endpoint

  1. Go to Settings > Webhooks in your Jump EHR dashboard
  2. Click Add Endpoint
  3. Enter your endpoint URL (must be HTTPS)
  4. Select the events you want to receive
  5. Click Create

Your webhook secret is displayed only once when creating the endpoint. Copy and store it securely.

2. Configure Your Server

Your server must:

  • Accept POST requests
  • Respond with a 2xx status code within 30 seconds
  • Verify the webhook signature (recommended)

URL Requirements

  • Must use HTTPS (HTTP not allowed in production)
  • Cannot point to internal/private IP addresses (127.x, 10.x, 172.16-31.x, 192.168.x)
  • Must be publicly accessible

Security

Signature Verification

Every webhook request includes a signature header for verification:

X-Webhook-Signature: t=1705312200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

The signature format is t=<timestamp>,v1=<signature> where:

  • t is the Unix timestamp when the webhook was sent
  • v1 is the HMAC-SHA256 signature

Verifying Signatures

To verify a webhook signature:

  1. Extract the timestamp and signature from the header
  2. Construct the signed payload: {timestamp}.{request_body}
  3. Compute HMAC-SHA256 using your webhook secret
  4. Compare with the provided signature

Node.js Example

const crypto = require('crypto');

function verifyWebhookSignature(payload, signature, secret) {
  const [tPart, v1Part] = signature.split(',');
  const timestamp = tPart.split('=')[1];
  const providedSignature = v1Part.split('=')[1];

  const signedPayload = `${timestamp}.${payload}`;
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  // Use timing-safe comparison
  return crypto.timingSafeEqual(
    Buffer.from(providedSignature),
    Buffer.from(expectedSignature)
  );
}

// Express.js example
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.headers['x-webhook-signature'];
  const payload = req.body.toString();

  if (!verifyWebhookSignature(payload, signature, process.env.WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(payload);
  console.log('Received event:', event.type);

  // Process the event
  switch (event.type) {
    case 'appointment.created':
      handleAppointmentCreated(event.data.object);
      break;
    case 'patient.updated':
      handlePatientUpdated(event.data.object, event.data.previous);
      break;
  }

  res.status(200).send('OK');
});

Python Example

import hmac
import hashlib

def verify_webhook_signature(payload: str, signature: str, secret: str) -> bool:
    parts = dict(part.split('=') for part in signature.split(','))
    timestamp = parts['t']
    provided_signature = parts['v1']

    signed_payload = f"{timestamp}.{payload}"
    expected_signature = hmac.new(
        secret.encode(),
        signed_payload.encode(),
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(provided_signature, expected_signature)

# Flask example
@app.route('/webhooks', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-Webhook-Signature')
    payload = request.get_data(as_text=True)

    if not verify_webhook_signature(payload, signature, WEBHOOK_SECRET):
        return 'Invalid signature', 401

    event = request.get_json()
    print(f"Received event: {event['type']}")

    # Process the event
    if event['type'] == 'appointment.created':
        handle_appointment_created(event['data']['object'])

    return 'OK', 200

Additional Headers

Each webhook request includes these headers:

HeaderDescription
X-Webhook-SignatureHMAC-SHA256 signature for verification
X-Webhook-Event-IDUnique event ID for deduplication
X-Webhook-Event-TypeEvent type name
User-AgentJumpEHR-Webhooks/1.0
Content-Typeapplication/json

Delivery & Retries

Delivery Behavior

  • Webhooks are delivered within seconds of the event occurring
  • Each delivery attempt has a 30-second timeout
  • Your endpoint must respond with a 2xx status code to confirm receipt

Retry Schedule

If delivery fails, Jump EHR retries with exponential backoff:

AttemptDelay
11 minute
24 minutes
316 minutes
464 minutes (~1 hour)
5256 minutes (~4 hours)

After 5 failed attempts, the delivery is marked as failed and not retried.

Success Criteria

A delivery is considered successful when your endpoint:

  • Responds with HTTP status 200-299
  • Responds within 30 seconds

Return a 2xx response quickly, then process the webhook asynchronously. This prevents timeouts for long-running operations.

Best Practices

1. Respond Quickly

Acknowledge the webhook immediately and process asynchronously:

app.post('/webhooks', (req, res) => {
  // Respond immediately
  res.status(200).send('OK');

  // Process asynchronously
  processWebhookAsync(req.body).catch(console.error);
});

2. Handle Duplicates

Use the X-Webhook-Event-ID header to detect and skip duplicate deliveries:

const processedEvents = new Set();

function handleWebhook(event, eventId) {
  if (processedEvents.has(eventId)) {
    console.log('Duplicate event, skipping');
    return;
  }

  processedEvents.add(eventId);
  // Process event...
}

3. Verify Signatures

Always verify webhook signatures in production to ensure requests are from Jump EHR.

4. Log Webhook Events

Keep logs of received webhooks for debugging and auditing:

function logWebhook(event, status) {
  console.log({
    timestamp: new Date().toISOString(),
    event_id: event.id,
    event_type: event.type,
    status: status
  });
}

Testing Webhooks

Test Endpoint

Use the Test button in the webhook settings to send a test event:

{
  "id": "evt_test_123",
  "type": "test.ping",
  "created_at": "2025-01-15T10:30:00Z",
  "organization_id": "org_xyz789",
  "data": {
    "object": {
      "message": "This is a test webhook"
    },
    "previous": null
  }
}

Local Development

For local development, use a tunneling service like ngrok to expose your local server:

ngrok http 3000

Then use the ngrok URL as your webhook endpoint during development.

Viewing Delivery History

View webhook delivery history in Settings > Webhooks > select your endpoint > Delivery Log.

The log shows:

  • Event type and ID
  • Delivery status (delivered, failed, pending)
  • Response status code
  • Number of attempts
  • Timestamps

Next Steps

On this page