Webhooks Integration
You may wish to create endpoints to receive asynchronous events from your Prequel integration, such as transfer failures. Webhook endpoints created using the POST /webhooks
endpoint can subscribe to any of Prequel’s Event Types and configure delivery via HTTPS, Slack, or Pagerduty.
Delivery Methods
HTTP POST and GET
Prequel supports HTTPS
callbacks to your custom webhook receiver. Creating a webhook endpoint as the generic_post
type will cause payloads to be sent as a JSON payload. The generic_get
type will deliver payloads as URL parameters. The payloads are defined in Event Types.
PagerDuty & Slack
You may wish to have events sent to specific vendors for special handling of webhook requests. Currently, PagerDuty and Slack are supported, with specific payload shapes according to the vendor specifications.
Authentication
You can provide an API key for the webhook to use if the destination requires one. Additionally, Prequel signs every payload and passes the signature through the X-Prequel-Webhook-Signature
header. See Verifying Webhooks for more information on handling the signature.
Versioning
Prequel uses a top-level “version” field. A version is represented by the date it was released. Currently, all webhooks use the version 2023-10-15
.
Contents & Structure
All event types follow this structure:
Headers
Header | Description |
---|---|
Content-Type | Always application/json |
X-Prequel-Webhook-Timestamp | Timestamp generated when the event is sent. |
X-Prequel-Webhook-Signature | Signature generated by Prequel using SHA-256 and RSA PKCS1 v1.5 signature scheme. See Verifying Webhooks. |
X-Prequel-Webhook-Digest | Optional utility for signature verification. See Verifying Webhooks. |
Body
{
“type”: “resource.event”,
“version”: “XXXX-XX-XX”,
“created_at”: …, // timestamp generated when the event is created
“data”: {
// event specific
}
}
Event Types
When creating a webhook, you must specify which events it should listen to. Webhooks created before October 31 2023 will continue to receive only transfer errors.
Webhooks are available for the following event types:
transfer.success
transfer.error
transfer.cancelled
export_source.created
export_source.updated
export_source.deleted
export_destination.created
export_destination.updated
export_destination.deleted
recipient.created
recipient.updated
recipient.deleted
export_magic_link.created
export_magic_link.deleted
Transfer events
transfer.success
transfer.success
This event has type transfer.success
. It is sent whenever Prequel identifies a successful transfer.
Example transfer success:
{
"type":"transfer.success",
"api_version": "2023-10-15",
"created_at": "2023-10-15T19:19:08+00:00",
"data":{
"destination_id":"00000000-0000-0000-0000-000000000000",
"destination_name":"Foo Bar",
"id_in_provider_system":"acme",
"in_app_url":"https://app.prequel.co/export/destinations/00000000-0000-0000-0000-000000000000/transfers",
"is_full_refresh":true,
"models":["users"],
"rows_transferred":1000,
"transfer_id":"00000000-0000-0000-0000-000000000000",
"transfer_log":"mock_data - succeeded",
"transfer_log_pretty":"users - succeeded",
"transfer_started_at": "2023-10-15T16:19:08+00:00",
"transfer_ended_at": "2023-10-15T18:19:08+00:00",
"transfer_status":"SUCCESS"
},
}
transfer.error
transfer.error
This event has type transfer.error
. It is sent whenever Prequel identifies a partial or full transfer failure. Note the following fields which do not appear on the transfer object itself:
transfer_blamed_party
: May be any offirst_party
,third_party
,prequel
,host
, orunknown
. Defaults toprequel
.first_party
indicates an error with your Prequel integration.third_party
indicates to an error caused by your customer; for example, a change in existing credentials for a destination.host
is only applicable if you are self-hosting Prequel.transfer_log
: Full log of the error.transfer_log_pretty
: Iftransfer_blamed_party
isthird_party
, a modified error string suitable for displaying to customers. Otherwise empty.
Example transfer error:
{
"type": "transfer.error",
"api_version": "2023-10-15",
"created_at": "2023-10-15T19:19:08+00:00",
"data": {
"destination_id": "00000000-0000-0000-0000-000000000000",
"destination_name": "Foo Bar",
"id_in_provider_system": "acme",
"models": ["users"],
"rows_transferred": 1179,
"transfer_blamed_party": "third_party",
"is_full_refresh": true,
"transfer_id": "00000000-0000-0000-0000-000000000000",
"transfer_status": "PARTIAL_FAILURE",
"transfer_log": "Exception in thread \"main\" org.apache.spark.SparkException: Job aborted due to stage failure: Task 3 in stage 4.0 failed 4 times, most recent failure: Lost task 3.3 in stage 4.0 (TID 132, executor 2): java.lang.NullPointerException at org.apache.spark.sql.execution.datasources.FileScanRDD$$anon$1.next(FileScanRDD.scala)",
"transfer_log_pretty": "Error due to the heat death of the universe",
"transfer_started_at": "2023-10-15T16:19:08+00:00",
"transfer_ended_at": "2023-10-15T18:19:08+00:00",
"in_app_url": "https://app.prequel.co/export/destinations/00000000-0000-0000-0000-000000000000/transfers"
}
}
transfer.cancelled
transfer.cancelled
This event has type transfer.cancelled
. It is sent whenever Prequel identifies a cancelled or killed transfer. A transfer could be cancelled by the user when it is in the PENDING
state, or it could be killed by the system or user if it is in the RUNNING
state. Both actions will trigger a transfer.cancelled
event.
{
"type": "transfer.cancelled",
"api_version": "2023-10-15",
"created_at": "2025-02-20T20:54:51.971902Z",
"data": {
"destination_id": "00000000-0000-0000-0000-000000000000",
"destination_name": "Foo Bar",
"id_in_provider_system": "acme",
"in_app_url": "https://app.prequel.co/export/destinations/00000000-0000-0000-0000-000000000000/transfers",
"is_full_refresh": false,
"models": ["users"],
"rows_transferred": 0,
"transfer_ended_at": "2025-02-20T20:54:51.971902Z",
"transfer_id": "00000000-0000-0000-0000-000000000000",
"transfer_log": "Pending transfer cancelled by user.",
"transfer_log_pretty": "",
"transfer_started_at": null,
"transfer_status": "CANCELLED"
}
}
Source events
export_source.created
export_source.created
This event has type export_source.created
. It is sent whenever Prequel identifies that a new source has been created. This example shows a new Snowflake source.
{
"type": "export_source.created",
"api_version": "2023-10-15",
"created_at": "2025-02-20T17:54:18.453379Z",
"data": {
"current_resource": {
"id": "00000000-0000-0000-0000-000000000000",
"host": "snowflake-webhook.region.cloud.snowflakecomputing.com",
"name": "Acme Inc",
"port": 443,
"vendor": "snowflake",
"database": "source_database",
"username": "snowflake_user",
"created_at": "2025-02-20T12:54:18.445223-05:00",
"updated_at": "2025-02-20T12:54:18.445223-05:00",
"disable_ssl": false,
"use_ssh_tunnel": false,
"ssh_tunnel_host": "",
"ssh_tunnel_port": 22,
"ssh_tunnel_username": "",
"gcp_iam_role_metadata": {
"type": "",
"audience": "",
"token_url": "",
"credential_source": {
"url": "",
"region_url": "",
"environment_id": "",
"regional_cred_verification_url": ""
},
"subject_token_type": "",
"service_account_impersonation_url": ""
},
"max_concurrent_transfers": 1,
"is_bucket_credentials_implicit": false,
"max_concurrent_queries_per_transfer": 1
},
"previous_resource": null
}
}
export_source.updated
export_source.updated
This event has type export_source.updated
. It is sent whenever Prequel identifies that an existing source has been updated. In this example, you can see the database
field has been updated to source_db_updated
.
{
"type": "export_source.updated",
"api_version": "2023-10-15",
"created_at": "2025-02-20T17:57:04.201273Z",
"data": {
"current_resource": {
"id": "00000000-0000-0000-0000-000000000000",
"host": "snowflake-webhook.region.cloud.snowflakecomputing.com",
"name": "Acme Inc",
"port": 443,
"vendor": "snowflake",
"database": "source_db_updated",
"username": "snowflake_user",
"created_at": "2025-02-20T12:54:18.445223-05:00",
"updated_at": "2025-02-20T12:57:04.195446-05:00",
"disable_ssl": false,
"use_ssh_tunnel": false,
"ssh_tunnel_host": "",
"ssh_tunnel_port": 22,
"ssh_tunnel_username": "",
"gcp_iam_role_metadata": {
"type": "",
"audience": "",
"token_url": "",
"credential_source": {
"url": "",
"region_url": "",
"environment_id": "",
"regional_cred_verification_url": ""
},
"subject_token_type": "",
"service_account_impersonation_url": ""
},
"max_concurrent_transfers": 1,
"is_bucket_credentials_implicit": false,
"max_concurrent_queries_per_transfer": 1
},
"previous_resource": {
"id": "00000000-0000-0000-0000-000000000000",
"host": "snowflake-webhook.region.cloud.snowflakecomputing.com",
"name": "Acme Inc",
"port": 443,
"vendor": "snowflake",
"database": "source_db",
"username": "snowflake_user",
"created_at": "2025-02-20T12:54:18.445223-05:00",
"updated_at": "2025-02-20T12:54:18.445223-05:00",
"disable_ssl": false,
"use_ssh_tunnel": false,
"ssh_tunnel_host": "",
"ssh_tunnel_port": 22,
"bucket_secret_key": "[redacted]",
"ssh_tunnel_username": "",
"gcp_iam_role_metadata": {
"type": "",
"audience": "",
"token_url": "",
"credential_source": {
"url": "",
"region_url": "",
"environment_id": "",
"regional_cred_verification_url": ""
},
"subject_token_type": "",
"service_account_impersonation_url": ""
},
"max_concurrent_transfers": 1,
"is_bucket_credentials_implicit": false,
"max_concurrent_queries_per_transfer": 1
}
}
}
export_source.deleted
export_source.deleted
This event has type export_source.deleted
. It is sent whenever Prequel identifies that an existing source has been deleted.
{
"type": "export_source.deleted",
"api_version": "2023-10-15",
"created_at": "2025-02-20T17:59:09.416441Z",
"data": {
"current_resource": null,
"previous_resource": {
"id": "00000000-0000-0000-0000-000000000000",
"host": "snowflake-webhook.region.cloud.snowflakecomputing.com",
"name": "Acme Inc",
"port": 443,
"vendor": "snowflake",
"database": "source_db_updated",
"username": "snowflake_user",
"created_at": "2025-02-20T12:54:18.445223-05:00",
"updated_at": "2025-02-20T12:57:04.195446-05:00",
"disable_ssl": false,
"use_ssh_tunnel": false,
"ssh_tunnel_host": "",
"ssh_tunnel_port": 22,
"ssh_tunnel_username": "",
"gcp_iam_role_metadata": {
"type": "",
"audience": "",
"token_url": "",
"credential_source": {
"url": "",
"region_url": "",
"environment_id": "",
"regional_cred_verification_url": ""
},
"subject_token_type": "",
"service_account_impersonation_url": ""
}
}
}
}
Destination events
export_destination.created
export_destination.created
This event has type export_destination.created
. It is sent whenever Prequel identifies that a new destination has been created.
{
"type": "export_destination.created",
"api_version": "2023-10-15",
"created_at": "2025-02-20T18:12:45.409267Z",
"data": {
"current_resource": {
"id": "00000000-0000-0000-0000-000000000000",
"host": "snowflake-webhook.region.cloud.snowflakecomputing.com",
"name": "Acme Inc",
"port": 443,
"schema": "webhook_schema",
"vendor": "snowflake",
"database": "destination_db",
"products": ["billing", "invoices"],
"username": "snowflake_user",
"created_at": "2025-02-20T13:12:45.360283-05:00",
"is_enabled": true,
"updated_at": "2025-02-20T13:12:45.360283-05:00",
"disable_ssl": false,
"recipient_id": "00000000-0000-0000-0000-000000000000",
"enabled_models": ["*"],
"use_ssh_tunnel": false,
"ssh_tunnel_host": "",
"ssh_tunnel_port": 0,
"frequency_minutes": 0,
"ssh_tunnel_username": "",
"gcp_iam_role_metadata": {
"type": "",
"audience": "",
"token_url": "",
"credential_source": {
"url": "",
"region_url": "",
"environment_id": "",
"regional_cred_verification_url": ""
},
"subject_token_type": "",
"service_account_impersonation_url": ""
},
"id_in_provider_system": "tenant_id",
"max_concurrent_transfers": 1,
"is_bucket_credentials_implicit": false,
"last_successful_transfer_ended_at": null,
"max_concurrent_queries_per_transfer": 1
},
"previous_resource": null
}
}
export_destination.updated
export_destination.updated
This event has type export_destination.updated
. It is sent whenever Prequel identifies that an existing destination has been updated.
{
"type": "export_destination.updated",
"api_version": "2023-10-15",
"created_at": "2025-02-20T18:15:11.411273Z",
"data": {
"current_resource": {
"id": "00000000-0000-0000-0000-000000000000",
"host": "snowflake-webhook.region.cloud.snowflakecomputing.com",
"name": "Acme Inc",
"port": 443,
"schema": "new_webhook_schema",
"vendor": "snowflake",
"database": "destination_db",
"products": ["billing", "invoices"],
"username": "snowflake_user",
"created_at": "2025-02-20T13:12:45.360283-05:00",
"is_enabled": true,
"updated_at": "2025-02-20T13:15:11.398688-05:00",
"disable_ssl": false,
"recipient_id": "00000000-0000-0000-0000-000000000000",
"enabled_models": ["*"],
"use_ssh_tunnel": false,
"ssh_tunnel_host": "",
"ssh_tunnel_port": 0,
"ssh_tunnel_username": "",
"gcp_iam_role_metadata": {
"type": "",
"audience": "",
"token_url": "",
"credential_source": {
"url": "",
"region_url": "",
"environment_id": "",
"regional_cred_verification_url": ""
},
"subject_token_type": "",
"service_account_impersonation_url": ""
},
"id_in_provider_system": "tenant_id",
"max_concurrent_transfers": 1,
"is_bucket_credentials_implicit": false,
"last_successful_transfer_ended_at": null,
"max_concurrent_queries_per_transfer": 1
},
"previous_resource": {
"id": "00000000-0000-0000-0000-000000000000",
"host": "snowflake-webhook.region.cloud.snowflakecomputing.com",
"name": "Acme Inc",
"port": 443,
"schema": "webhook_schema",
"vendor": "snowflake",
"database": "destination_db",
"products": ["billing", "invoices"],
"username": "snowflake_user",
"created_at": "2025-02-20T13:12:45.360283-05:00",
"is_enabled": true,
"updated_at": "2025-02-20T13:12:45.360283-05:00",
"disable_ssl": false,
"recipient_id": "00000000-0000-0000-0000-000000000000",
"enabled_models": ["*"],
"use_ssh_tunnel": false,
"ssh_tunnel_host": "",
"ssh_tunnel_port": 0,
"ssh_tunnel_username": "",
"gcp_iam_role_metadata": {
"type": "",
"audience": "",
"token_url": "",
"credential_source": {
"url": "",
"region_url": "",
"environment_id": "",
"regional_cred_verification_url": ""
},
"subject_token_type": "",
"service_account_impersonation_url": ""
},
"id_in_provider_system": "tenant_id",
"max_concurrent_transfers": 1,
"is_bucket_credentials_implicit": false,
"last_successful_transfer_ended_at": null,
"max_concurrent_queries_per_transfer": 1
}
}
}
export_destination.deleted
export_destination.deleted
This event has type export_destination.deleted
. It is sent whenever Prequel identifies that an existing destination has been deleted.
{
"type": "export_destination.deleted",
"api_version": "2023-10-15",
"created_at": "2025-02-20T18:17:31.963946Z",
"data": {
"current_resource": null,
"previous_resource": {
"id": "00000000-0000-0000-0000-000000000000",
"host": "snowflake-webhook.region.cloud.snowflakecomputing.com",
"name": "Acme Inc",
"port": 443,
"schema": "new_webhook_schema",
"vendor": "snowflake",
"database": "destination_db",
"products": ["billing", "invoices"],
"username": "snowflake_user",
"created_at": "2025-02-20T13:12:45.360283-05:00",
"is_enabled": true,
"updated_at": "2025-02-20T13:15:11.398688-05:00",
"disable_ssl": false,
"recipient_id": "00000000-0000-0000-0000-000000000000",
"enabled_models": ["*"],
"use_ssh_tunnel": false,
"ssh_tunnel_host": "",
"ssh_tunnel_port": 0,
"ssh_tunnel_username": "",
"gcp_iam_role_metadata": {
"type": "",
"audience": "",
"token_url": "",
"credential_source": {
"url": "",
"region_url": "",
"environment_id": "",
"regional_cred_verification_url": ""
},
"subject_token_type": "",
"service_account_impersonation_url": ""
},
"id_in_provider_system": "tenant_id",
"max_concurrent_transfers": 1,
"is_bucket_credentials_implicit": false,
"last_successful_transfer_ended_at": null,
"max_concurrent_queries_per_transfer": 1
}
}
}
Recipient events
recipient.created
recipient.created
This event has type recipient.created
. It is sent whenever Prequel identifies that a new recipient has been created.
{
"type": "recipient.created",
"api_version": "2023-10-15",
"created_at": "2025-02-20T17:44:10.769913Z",
"data": {
"current_resource": {
"id": "00000000-0000-0000-0000-000000000000",
"name": "Acme Inc",
"id_in_provider_system": "acme_inc",
"products": ["billing", "invoices"],
"created_at": "2025-02-20T12:44:10.763224-05:00",
"updated_at": "2025-02-20T12:44:10.763224-05:00",
},
"previous_resource": null
}
}
recipient.updated
recipient.updated
This event has type recipient.updated
. It is sent whenever Prequel identifies that an existing recipient has been updated. In this example, you can see that the products
field has been updated to include invoices
.
{
"type": "recipient.updated",
"api_version": "2023-10-15",
"created_at": "2025-02-20T17:45:56.752967Z",
"data": {
"current_resource": {
"id": "00000000-0000-0000-0000-000000000000",
"name": "Acme Inc",
"id_in_provider_system": "acme_inc",
"products": ["billing", "invoices"],
"created_at": "2025-02-20T12:44:10.763224-05:00",
"updated_at": "2025-02-20T12:45:56.746663-05:00",
},
"previous_resource": {
"id": "00000000-0000-0000-0000-000000000000",
"name": "Acme Inc",
"id_in_provider_system": "acme_inc",
"products": ["billing"],
"created_at": "2025-02-20T12:44:10.763224-05:00",
"updated_at": "2025-02-20T12:44:10.763224-05:00",
}
}
}
recipient.deleted
recipient.deleted
This event has type recipient.deleted
. It is sent whenever Prequel identifies that an existing recipient has been deleted.
{
"type": "recipient.deleted",
"api_version": "2023-10-15",
"created_at": "2025-02-20T17:48:35.128964Z",
"data": {
"current_resource": null,
"previous_resource": {
"id": "00000000-0000-0000-0000-000000000000",
"name": "Acme Inc",
"id_in_provider_system": "acme_inc",
"products": ["billing", "invoices"],
"created_at": "2025-02-20T12:44:10.763224-05:00",
"updated_at": "2025-02-20T12:45:56.746663-05:00"
}
}
}
Magic Link events
export_magic_link.created
export_magic_link.created
This event has type export_magic_link.created
. It is sent whenever Prequel identifies that a new magic link has been created.
{
"type": "export_magic_link.created",
"api_version": "2023-10-15",
"created_at": "2025-02-20T21:13:53.155964Z",
"data": {
"current_resource": {
"id": "00000000-0000-0000-0000-000000000000",
"host": "snowflake-webhook.region.cloud.snowflakecomputing.com",
"link": "https://app.prequel.co/orgs/00000000-0000-0000-0000-000000000000/links/00000000-0000-0000-0000-000000000000",
"name": "Webhook Magic Link Acme Inc",
"vendor": "snowflake",
"products": ["billing", "invoices"],
"created_at": "2025-02-20T16:13:53.149884-05:00",
"bucket_name": "",
"recipient_id": "00000000-0000-0000-0000-000000000000",
"has_been_used": false,
"ssh_public_key": "ssh-rsa...prequel-ssh-tunneling-public-key",
"available_models": ["Customers", "Invoices"],
"set_destination_enabled": true
},
"previous_resource": null
}
}
export_magic_link.deleted
export_magic_link.deleted
This event has type export_magic_link.deleted
. It is sent whenever Prequel identifies that an existing magic link has been deleted.
{
"type": "export_magic_link.deleted",
"api_version": "2023-10-15",
"created_at": "2025-02-20T21:16:58.035652Z",
"data": {
"current_resource": null,
"previous_resource": {
"id": "00000000-0000-0000-0000-000000000000",
"host": "snowflake-webhook.region.cloud.snowflakecomputing.com",
"link": "https://app.prequel.co/orgs/00000000-0000-0000-0000-000000000000/links/00000000-0000-0000-0000-000000000000",
"name": "Webhook Magic Link Acme Inc",
"vendor": "snowflake",
"products": ["billing", "invoices"],
"created_at": "2025-02-20T16:13:53.149884-05:00",
"bucket_name": "",
"recipient_id": "00000000-0000-0000-0000-000000000000",
"has_been_used": false,
"ssh_public_key": "ssh-rsa...prequel-ssh-tunneling-public-key",
"available_models": ["Customers", "Invoices"],
"id_in_provider_system": "acme",
"set_destination_enabled": true
}
}
}
Verifying Webhooks
Prequel provides a unique signature in the HTTP header of each webhook request. You can use this signature and your account’s webhook public key to verify that the data you receive is from Prequel.
Webhook Signatures
Prequel’s approach to webhook signatures is based on asymmetric cryptography. Prequel generates an RSA private and public key pair for your account’s webhook integration. After generating a webhook, Prequel digitally signs the payload with the private key. On receiving the webhook, you can use the public key to verify the authenticity and freshness of this signature. See Verifying The Signature.
Relevant headers
Header | Description |
---|---|
X-Prequel-Webhook-Timestamp | Timestamp the webhook was sent, in RFC 3339 format. |
X-Prequel-Webhook-Signature | Hex-encoded signature generated by Prequel using the signing data. |
(Maybe) X-Prequel-Webhook-Digest | Hex-encoded SHA-256 hash of the payload only, provided by Prequel for debugging purposes. |
Verify the signature
The general steps to verify a signature are outlined below.
1. Retrieve your webhook key
You can retrieve your webhook key by hitting the following API endpoint /public/signatures/webhhook-public-key
. See the API reference.
2. Reconstruct the signing data
Extract the timestamp provided in the X-Prequel-Webhook-Timestamp
header. The date is in RFC 3339 format and looks something like:
X-Prequel-Webhook-Timestamp: 2023-10-15T14:30:00Z
The signing data is in the format timestamp.body
, constructed by concatenating
- The timestamp
- The character
.
- The request body, i.e. the raw JSON payload
Finally, hash the full signing data using SHA-256
.
Warning
Prequel uses the raw body of the request to generate a signature. You should hash the raw body string (or bytes) before deserializing the JSON payload. Frameworks that parse the response body may introduce subtle changes, such as removing characters or changing key-sort order.
Validate the response hash
Prequel hashes the raw body only and provides the result in the X-Prequel-Webhook-Digest
header. You can use this to validate that you are handling the response body correctly by hex-decoding the value and comparing it to your SHA-256 hash of the raw body. You should not use this to confirm the signature.
3. Confirm the signature
Once you’ve reconstructed and hashed the signing data, you can sign the data with your public key and compare the output to the signature in the X-Prequel-Webhook-Signature
header. Make sure to decode Prequel's signature from hex before comparing.
Prequel uses the PKCS1 v1.5 signature scheme.
4. Verify timing
A replay attack is when an attacker intercepts a valid payload and its signature, then re-transmits them. The provided timestamp is verified by the signature, so an attacker can’t change the timestamp without invalidating the signature. However, even if the attacker cannot alter the data, they may produce side-effects from processing the same event multiple times. We recommend defining a time window (such as 5 minutes) and rejecting events that are too old.
Updated 1 day ago