What are webhooks
Webhooks are used to send real-time notifications of events occurring in your OpenPhone workspace to an application you provide. Webhooks are configured in your workspace settings. When an event occurs, OpenPhone calls the webhook with a payload describing the event. Your application can examine the event payload and take whatever actions are appropriate for your use case. Webhook applications (also known as webhook handlers) typically run in a cloud-based backend environment. This document is written for software engineers who will be creating webhook applications on behalf of workspace owners.
Note: References to the OpenPhone app in this document refer to the OpenPhone web and desktop apps. Webhook settings currently cannot be managed in the mobile apps.
Here's what the “Create webhook” form looks like for owner and admin users:
Available events
The following events are available for webhooks.
Messaging
message.received | Indicates a text message was received by a workspace phone number. The text message may contain attached media (for example, images in JPEG format). |
message.delivered | Indicates a text message was sent from a workspace phone number and delivered. The text message may contain attached media (for example, images in JPEG format). |
call.summary.completed | indicates that a call summary is available in the event payload |
call.transcript.completed | indicates a call transcript is available in the event payload |
Voice
call.ringing | Indicates a call is being received by a workspace phone number. |
call.completed | Indicates a call was completed. The call may or may not have been answered. If the call was an incoming call and not answered, a voicemail recording may be included. |
call.recording.completed | Indicates a call recording is available at the URL indicated in the event payload. |
Contacts
contact.updated | Indicates a contact was created or updated. |
contact.deleted | Indicates a contact was deleted. |
Configuring webhooks
Webhooks are configured in the OpenPhone app under workspace settings.
The following parameters can be configured:
URL | Required | The URL for your webhook handler. This must be an http or https URL. For production use, or whenever sensitive data may be sent by the webhook, https is highly recommended. |
Label | Optional | A label for the webhook. This is purely informational for workspace owners and admins (for example, may describe the purpose of the webhook). |
Event types | Required | The event types which trigger the webhook. One or more can be selected. |
Resources | Required | The phone numbers (for call and message events) or users/groups (for contact events) which trigger the webhook. “All” can be selected. |
Writing webhooks
A webhook handler must be written and made available at a URL. A webhook handler can be written in any programming language capable of receiving HTTP requests and sending HTTP responses.
Webhooks are called using the POST method. The body of the POST request contains the event JSON.
The webhook handler should respond with the proper HTTP status code. No response body is required. A 2xx response code should be used to indicate the webhook call was successful. If OpenPhone does not receive a 2xx response, or if no response is received within the timeout period (currently 10 seconds), OpenPhone will initiate a retry sequence (described below).
The webhook handler should verify the signature of the request.
Webhook signatures
Webhook calls are signed so webhook handlers can validate webhook calls came from OpenPhone (vs an imposter or attacker).
The signature appears in the openphone-signature header. For example:
'openphone-signature': 'hmac;1;1639710054089;mw1K4fvh5m9XzsGon4C5N3KvL0bkmPZSAy
b/9Vms2Qo=’
The format is:
<scheme>;<version>;<timestamp>;<signature>
scheme | The signature scheme. Currently, this is always “hmac”. |
version | The signature version. Currently this is always “1”. |
timestamp | The timestamp when the signature was generated. |
signature | The base64 encoded digital signature. |
Signature verification
To verify the signature, given the signing key:
- Extract the timestamp and signature from the header.
- Concatenate the timestamp with the webhook payload, using a period (.) as a separator. The webhook payload is sent with Content-Type: application/json, so you need to make sure the webhook payload is a string before concatenating it with the timestamp. In addition, the string must not contain any new lines or any other whitespace. Any blanks or spaces must be removed from the string.
- Compute the SHA256 HMAC using the signing key. The signing key can be obtained from the webhook details page in the OpenPhone app, by clicking on the ellipses at the top right and then clicking "Reveal signing secret". The signing key you see via “Reveal signing secret” is base-64 encoded, so you need to convert it to binary before using it for the HMAC computation.
- Compare the result of the HMAC computation to the signature in the header. If they are equal, the signature is verified.
Sample code (node.js):
const express = require("express")
const bodyParser = require('body-parser')
const crypto = require('node:crypto');
const app = express()
const port = 8000
app.use(bodyParser.json())
app.post("/", function (req, res) {
// signingKey is from "Reveal Signing Secret" in the OpenPhone app.
const signingKey = 'R2ZLM2o0bFhBNVpyUnU2NG9mYXQ1MHNyR3pvSUhIVVg='
// Parse the fields from the openphone-signature header.
const signature = req.headers['openphone-signature']
const fields = signature.split(';')
const timestamp = fields[2]
const providedDigest = fields[3]
// Compute the data covered by the signature.
const signedData = timestamp + '.' + JSON.stringify(req.body)
// Convert the base64-encoded signing key to binary.
const signingKeyBinary = Buffer.from(signingKey, 'base64').toString('binary')
// Compute the SHA256 HMAC digest.
// Obtain the digest in base64-encoded form for easy comparison with
// the digest provided in the openphone-signature header.
const computedDigest = crypto.createHmac('sha256',signingKeyBinary)
.update(Buffer.from(signedData,'utf8'))
.digest('base64')
// Make sure the computed digest matches the digest in the openphone header.
if (providedDigest === computedDigest) {
console.log(`signature verification succeeded`)
} else {
console.log(`signature verification failed`)
}
res.send({})
});
app.listen(port, function () {
console.log(`webhook server listening on port ${port}`)
})
Sample code (Python):
import base64
import hmac
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route('/', methods=['POST'])
def handle_webhook_call():
# signingKey is from "Reveal Signing Secret" in the OpenPhone app.
signing_key = 'R2ZLM2o0bFhBNVpyUnU2NG9mYXQ1MHNyR3pvSUhIVVg='
# Parse the fields from the openphone-signature header.
signature = request.headers['openphone-signature']
fields = signature.split(';')
timestamp = fields[2]
provided_digest = fields[3]
# Compute the data covered by the signature as bytes.
signed_data_bytes = b''.join([timestamp.encode(), b'.', request.data])
# Convert the base64-encoded signing key to bytes.
signing_key_bytes = base64.b64decode(signing_key)
# Compute the SHA256 HMAC digest.
# Obtain the digest in base64-encoded form for easy comparison with
# the digest provided in the openphone-signature header.
hmac_object = hmac.new(signing_key_bytes, signed_data_bytes, 'sha256')
computed_digest = base64.b64encode(hmac_object.digest()).decode()
# Make sure the computed digest matches the digest in the openphone header.
if provided_digest == computed_digest:
print('signature verification succeeded')
else:
print('signature verification failed')
return jsonify({})
if __name__ == '__main__':
app.run(host= '0.0.0.0',port=8000,debug=True)
Note: This code assumes there is only one signature applied to the webhook. In the future, OpenPhone may apply multiple signatures to a webhook. In this case, the header will contain multiple <scheme>;<version>;<timestamp>;<signature> separated by commas. If desired, you could first split the header value on , then apply the logic above to each signature.
Replay attacks
To help guard against replay attacks, webhook handlers may wish to apply a tolerance between the timestamp in the openphone-signature header and the current time. If the signature is too old, the payload can be rejected. A new timestamp is generated for every webhook call, so if the webhook call is retried, it will contain a different timestamp and therefore a different signature.
Retry sequence
As described above, if a webhook call fails, OpenPhone will initiate a retry sequence.
The retry sequence uses an exponential backoff which delays the retry based on the number of retry attempts. This prioritizes lower delivery latency closer to the event time. As a result, initially, retry attempts occur quickly, and as time goes on, there is more delay between retry attempts.
If the webhook call cannot be completed successfully within 3 days, the retries stop and an email is sent to the user who created the webhook. The webhook call is marked with the ‘failure’ status in OpenPhone.
The OpenPhone app can be used to retry a webhook call at any time. If the webhook call is in ‘failure’ status, and the retry succeeds, the webhook call is placed in the ‘success’ status.
Testing webhooks
Webhook handlers can be tested in several different ways.
Without OpenPhone
Curl, Postman, Insomnia, or some other HTTP client can be used to send POST requests from your local machine to your webhook URL. In this case, your webhook handler could be running at a local URL (such as http://localhost), or it could be running at a URL which is globally reachable from anywhere on the Internet.
Using the Send Test Request feature
The Send Test Request feature in the OpenPhone app will send a POST request to your webhook handler with a sample event JSON. The sample event JSON has the same format as a real live event. This option is useful for making sure OpenPhone can call your webhook URL and for testing signature verification. To use this option, your webhook handler must be running at a URL which is globally reachable from anywhere on the Internet.
Note: The Send Test Request feature is located on the Webhook details page, by clicking on the ellipses at the top right.
Triggering an actual event
You can test your webhook by triggering an actual event in OpenPhone. For example, you could configure a webhook for the message.received event, specifying your OpenPhone phone number for the phone numbers the webhook applies to. Then, send a text message to your OpenPhone phone number from some other phone number. This will cause the webhook to be triggered and the message.received event sent to your webhook handler.
Troubleshooting
If there is an issue with your webhook, check the following.
- Make sure the webhook URL is correct.
- Make sure the webhook is enabled.
- Make sure the proper event types are selected.
- Make sure the proper phone numbers are selected (or, for contact events, users/groups).
- Make sure your webhook is sending an HTTP response with a 2xx status code.
- Check the events log for the webhook (located on the Webhook details page).
Sample event payloads
message.received
{
"id": "EVc67ec998b35c41d388af50799aeeba3e",
"object": "event",
"apiVersion": "v2",
"createdAt": "2022-01-23T16:55:52.557Z",
"type": "message.received",
"data": {
"object": {
"id": "AC24a8b8321c4f4cf2be110f4250793d51",
"object": "message",
"from": "+14155550100",
"to": "+13105550199",
"direction": "incoming",
"body": "Hello",
"media": [
{
"url": "https://storage.googleapis.com/opstatics-dev/6c908000ada94d9fb206649ecb8cc928",
"type": "image/jpeg"
}
],
"status": "received",
"createdAt": "2022-01-23T16:55:52.420Z",
"userId": "USu5AsEHuQ",
"phoneNumberId": "PNtoDbDhuz",
"conversationId": "CN78ba0373683c48fd8fd96bc836c51f79"
}
}
}
message.delivered
{
"id": "EVdefd85c2c3b740429cf28ade5b69bcba",
"object": "event",
"apiVersion": "v2",
"createdAt": "2022-01-23T17:05:56.220Z",
"type": "message.delivered",
"data": {
"object": {
"id": "ACcdcc2668c4134c3cbfdacb9e273cac6f",
"object": "message",
"from": "+13105550199",
"to": "+14155550100",
"direction": "outgoing",
"body": "Have a nice day",
"media": [
{
"url": "https://opstatics-dev.s3.amazonaws.com/i/ab6084db-5259-42c0-93c1-e17fb2628567.jpeg",
"type": "image/jpeg"
}
],
"status": "delivered",
"createdAt": "2022-01-23T17:05:45.195Z",
"userId": "USu5AsEHuQ",
"phoneNumberId": "PNtoDbDhuz",
"conversationId": "CN78ba0373683c48fd8fd96bc836c51f79"
}
}
}
call.ringing
{
"id": "EV95c3708f9112412a834cc8d415470cd8",
"object": "event",
"apiVersion": "v2",
"createdAt": "2022-01-23T17:07:51.454Z",
"type": "call.ringing",
"data": {
"object": {
"id": "ACbaee66e137f0467dbed5ad4bc8d60800",
"object": "call",
"from": "+14155550100",
"to": "+13105550199",
"direction": "incoming",
"media": [],
"voicemail": null,
"status": "ringing",
"createdAt": "2022-01-23T17:07:51.116Z",
"answeredAt": null,
"completedAt": null,
"userId": "USu5AsEHuQ",
"phoneNumberId": "PNtoDbDhuz",
"conversationId": "CN78ba0373683c48fd8fd96bc836c51f79"
}
}
}
call.completed (incoming call)
{
"id": "EVd39d3c8d6f244d21a9131de4fc9350d0",
"object": "event",
"apiVersion": "v2",
"createdAt": "2022-01-24T19:22:25.427Z",
"type": "call.completed",
"data": {
"object": {
"id": "ACa29ee906a4e04312a6928427b1c21721",
"object": "call",
"from": "+14145550100",
"to": "+13105550199",
"direction": "incoming",
"media": [],
"voicemail": {
"url": "https://m.openph.one/static/85ad4740be6048e4a80efb268d347482.mp3",
"type": "audio/mpeg",
"duration": 7
},
"status": "completed",
"createdAt": "2022-01-24T19:21:59.545Z",
"answeredAt": null,
"completedAt": "2022-01-24T19:22:19.000Z",
"userId": "USu5AsEHuQ",
"phoneNumberId": "PNtoDbDhuz",
"conversationId": "CN78ba0373683c48fd8fd96bc836c51f79"
}
}
}
call.completed (outgoing call)
{
"id": "EV348de11e4b134fa48017ac45a251dd3e",
"object": "event",
"apiVersion": "v2",
"createdAt": "2022-01-24T19:28:45.370Z",
"type": "call.completed",
"data": {
"object": {
"id": "AC7ab6f57e62924294925d0ea961de7dc5",
"object": "call",
"from": "+13105550199",
"to": "+14155550100",
"direction": "outgoing",
"media": [],
"voicemail": null,
"status": "completed",
"createdAt": "2022-01-24T19:28:33.892Z",
"answeredAt": "2022-01-24T19:28:42.000Z",
"completedAt": "2022-01-24T19:28:45.000Z",
"userId": "USu5AsEHuQ",
"phoneNumberId": "PNtoDbDhuz",
"conversationId": "CN78ba0373683c48fd8fd96bc836c51f79"
}
}
}
call.recording.completed
{
"id": "EVda6e196255814311aaac1983005fa2d9",
"object": "event",
"apiVersion": "v2",
"createdAt": "2022-01-24T19:30:55.400Z",
"type": "call.recording.completed",
"data": {
"object": {
"id": "AC0d3b9011efa041d78c864019ad9e948c",
"object": "call",
"from": "+14155550100",
"to": "+13105550199",
"direction": "incoming",
"media": [
{
"url": "https://storage.googleapis.com/opstatics-dev/b5f839bc72a24b33a7fc032f78777146.mp3",
"type": "audio/mpeg",
"duration": 7
}
],
"voicemail": null,
"status": "completed",
"createdAt": "2022-01-24T19:30:34.675Z",
"answeredAt": "2022-01-24T19:30:38.000Z",
"completedAt": "2022-01-24T19:30:48.000Z",
"userId": "USu5AsEHuQ",
"phoneNumberId": "PNtoDbDhuz",
"conversationId": "CN78ba0373683c48fd8fd96bc836c51f79"
}
}
}
contact.updated and contact.deleted
The contact.updated and contact.deleted events have the same payload.
{
"id": "EVe844e47e9fa4494d9acfa1144839ed94",
"object": "event",
"createdAt": "2022-01-24T19:44:09.579Z",
"apiVersion": "v2",
"type": "contact.updated",
"data": {
"object": {
"id": "CT61eeff33f3b14cfe6358cb52",
"object": "contact",
"firstName": "Jane",
"lastName": "Smith",
"company": "Comp Inc",
"role": "Agent",
"pictureUrl": null,
"fields": [
{
"name": "Phone",
"type": "phone-number",
"value": "+14155551212"
},
{
"name": "Email",
"type": "email",
"value": null
},
{
"name": "Prop1",
"type": "string",
"value": "Value12"
}
],
"notes": [
{
"text": "@USu5AsEHuQ mynote 🙂",
"enrichment": {
"taggedIds": {
"groupIds": [],
"userIds": [
"USu5AsEHuQ"
],
"orgIds": []
},
"tokens": {
"USu5AsEHuQ": {
"token": "USu5AsEHuQ",
"replacement": "Tom Smith",
"type": "mention",
"locations": [
{
"startIndex": 1,
"endIndex": 11
}
]
}
}
},
"createdAt": "2022-01-24T19:35:38.323Z",
"updatedAt": "2022-01-24T19:35:38.323Z",
"userId": "USu5AsEHuQ"
}
],
"sharedWith": [
"USu5AsEHuQ"
],
"createdAt": "2022-01-24T19:35:38.318Z",
"updatedAt": "2022-01-24T19:44:09.565Z",
"userId": "USu5AsEHuQ"
}
}
}
call.summary.completed
{
"id": "EVc86d16fed5314cf6bd7bf13f11c65fd2",
"object": "event",
"apiVersion": "v3",
"createdAt": "2024-09-05T15:30:24.213Z",
"type": "call.summary.completed",
"data": {
"object": {
"object": "callSummary",
"callId": "AC641569d25e0a4489ad807409d9bee3fd",
"status": "completed",
"summary": [
"this is your call summary."
],
"nextSteps": [
"these are your next steps."
]
}
}
}
call.transcript.completed
{
"id": "EV67e3564088bf4badb6593cd7db8f2832",
"object": "event",
"apiVersion": "v3",
"createdAt": "2024-09-05T15:30:18.629Z",
"type": "call.transcript.completed",
"data": {
"object": {
"object": "callTranscript",
"callId": "AC741569d25e0a4489ad804709d9bee3fc",
"createdAt": "2024-09-05T15:30:18.198Z",
"dialogue": [
{
"end": 73.57498,
"start": 0.03998,
"userId": "UStpTEr9a6",
"content": "hello world",
"identifier": "+12345678901"
},
{
"end": 1.52,
"start": 1.12,
"userId": "USiAYGldYE",
"content": "hi there",
"identifier": "+19876543210"
}
],
"duration": 87.614685,
"status": "completed"
}
}
}
If you have any other questions, please submit a request here. We're happy to help!