Repository: rickbergfalk/sqlpad
Pull request #790 (2020-07-13 19:27:07)

Add webhooks
Initial work to support webhooks

This draft PR has hooks proposal for user and query created, events that are used to send email on user additions, and slack messages when a query is saved respectively. 

Draft of documentation below. @eladeyal-intel do you any feedback on this before I get too far in adding hooks for batch created (query run/executed) and query finished/results received?

Documentation:

# Webhooks

?> Available `5.2.0`

A variety of webhooks are available to extend the functionality of SQLPad.

To enable a specific webhook, set `SQLPAD_WEBHOOK_ENABLED` to `true`, and provide a URL for the event you would like to subscribe to. HTTP and HTTPS URLs are supported. This URL will be sent an HTTP POST with a payload of relevant information when the event occurs.

To ensure a webhook call is valid, `SQLPAD_WEBHOOK_SECRET` may be configured, and validated on every webhook call.

The webhook POST will contain the following headers:

- `Content-Type`: `application/json`
- `SQLPad-Secret`: `SQLPAD_WEBHOOK_SECRET value`

The request body sent to the URL varies by event. Every body will have an `action` field with the name of the action, as well as `sqlpadUrl`, which will contain the URL to the SQLPad instance that sent the action (if `PUBLIC_URL` is configured).

## User Created

The user created webhook is sent whenever a user is added via the users API/UI or auto user creation (if enabled and supported via authentication configured). This hook can be used to automate user invites, sending a message to users inviting them to sign up (if using local user/password authentication) or sign in (if using a single sign on).

**Env**: `SQLPAD_WEBHOOK_USER_CREATED_URL`

**Payload Body**:

```json
{
  "action": "user_created",
  "sqlpadUrl": "http://mysqlpad.com:9000/sqlpad",
  "user": {
    "id": "a57acb9e-859e-44b7-83a8-42c5cbb809ce",
    "name": "user1",
    "email": "user1@test.com",
    "role": "editor",
    "createdAt": "2020-07-19T18:54:18.858Z"
  }
}
```

## Query Created

Fires whenever a query is created (initial query save). This is not fired on query execution (see batch created).

**Env**: `SQLPAD_WEBHOOK_QUERY_CREATED_URL`

**Payload Body**:

```json
{
  "action": "query_created",
  "sqlpadUrl": "http://mysqlpad.com:9000/sqlpad",
  "query": {
    "id": "d0eb347e-864a-4058-bcb2-5456adf5474f",
    "name": "test query",
    "queryText": "SELECT * FROM some_table",
    "tags": ["one", "two"],
    "chart": null,
    "createdByUser": {
      "id": "92185834-42d3-4b63-b0d4-6d81e5e83dfd",
      "name": null,
      "email": "admin@test.com"
    },
    "createdAt": "2020-07-19T18:57:14.522Z"
  }
}
```

## Batch Created

Fires whenever a batch is created. A batch is one or more queries, and is the API used to execute queries in the UI.

**Env**: `SQLPAD_WEBHOOK_BATCH_CREATED_URL`

**Payload Body**:

```json
{
  "action": "batch_created",
  "sqlpadUrl": "",
  "batch": {
    "id": "c169c7fc-0b9b-482a-8fe6-c9b3486d2f88",
    "queryId": "2f3e9bb6-cadf-4d52-a0c0-43089353193f",
    "name": null,
    "connectionId": "6ffeeb1d-1142-4004-b9f8-91cef0d15850",
    "connectionClientId": null,
    "status": "started",
    "startTime": "2020-07-19T19:01:09.980Z",
    "stopTime": null,
    "durationMs": null,
    "batchText": "SELECT 1 AS id, 'blue' AS color",
    "selectedText": "SELECT 1 AS id, 'blue' AS color",
    "chart": null,
    "userId": "50076232-f06f-4a80-8a4d-fc6b8b265830",
    "createdAt": "2020-07-19T19:01:09.980Z",
    "updatedAt": "2020-07-19T19:01:09.980Z",
    "statements": [
      {
        "id": "9fc69c47-8fda-4d7d-90a7-8236733e4dbe",
        "batchId": "c169c7fc-0b9b-482a-8fe6-c9b3486d2f88",
        "sequence": 1,
        "statementText": "SELECT 1 AS id, 'blue' AS color",
        "status": "queued",
        "startTime": null,
        "stopTime": null,
        "durationMs": null,
        "columns": null,
        "rowCount": null,
        "resultsPath": null,
        "incomplete": null,
        "error": null,
        "createdAt": "2020-07-19T19:01:09.981Z",
        "updatedAt": "2020-07-19T19:01:09.981Z"
      }
    ]
  },
  "user": {
    "id": "50076232-f06f-4a80-8a4d-fc6b8b265830",
    "name": null,
    "email": "admin@test.com",
    "role": "admin",
    "createdAt": "2020-07-19T19:01:09.594Z"
  },
  "connection": {
    "id": "6ffeeb1d-1142-4004-b9f8-91cef0d15850",
    "name": "test connection",
    "driver": "sqlite"
  }
}
```

## Batch Finished

Fires whenever a batch finished running. Payload contains summary information for success, or first error on failure. To receive results, use statement finished hook.

**Env**: `SQLPAD_WEBHOOK_BATCH_FINISHED_URL`

**Payload Body**:

```js
{
  "action": "batch_finished",
  "sqlpadUrl": "",
  "batch": {
    "id": "40cf0e10-2bd1-4a02-92f8-340a99775671",
    "queryId": "026ca49e-491e-4520-9ead-76bcdb287d28",
    "name": null,
    "connectionId": "668489b8-6049-43a8-a7af-0f34abf1984c",
    "connectionClientId": null,
    "status": "finished",
    "startTime": "2020-07-19T19:01:57.696Z",
    "stopTime": "2020-07-19T19:01:57.830Z",
    "durationMs": 15,
    "batchText": "SELECT 1 AS id, 'blue' AS color",
    "selectedText": "SELECT 1 AS id, 'blue' AS color",
    "chart": null,
    "userId": "d49295f7-29b8-4ba5-be10-218638d02af7",
    "createdAt": "2020-07-19T19:01:57.696Z",
    "updatedAt": "2020-07-19T19:01:57.830Z",
    "statements": [
      {
        "id": "396e8043-c766-4fec-b441-82dbcdf16473",
        "batchId": "40cf0e10-2bd1-4a02-92f8-340a99775671",
        "sequence": 1,
        "statementText": "SELECT 1 AS id, 'blue' AS color",
        "status": "finished",
        "startTime": "2020-07-19T19:01:57.815Z",
        "stopTime": "2020-07-19T19:01:57.821Z",
        "durationMs": 6,
        "columns": [
          {
            "datatype": "number",
            "max": 1,
            "min": 1,
            "maxValueLength": 0,
            "name": "id"
          },
          {
            "datatype": "string",
            "max": null,
            "min": null,
            "maxValueLength": 4,
            "name": "color"
          }
        ],
        "rowCount": 1,
        "resultsPath": "results\\396\\396e8043-c766-4fec-b441-82dbcdf16473.json",
        "incomplete": false,
        "error": null,
        "createdAt": "2020-07-19T19:01:57.697Z",
        "updatedAt": "2020-07-19T19:01:57.828Z"
      }
    ]
  },
  "user": {
    "id": "d49295f7-29b8-4ba5-be10-218638d02af7",
    "name": null,
    "email": "admin@test.com",
    "role": "admin",
    "createdAt": "2020-07-19T19:01:57.325Z"
  },
  "connection": {
    "id": "668489b8-6049-43a8-a7af-0f34abf1984c",
    "name": "test connection",
    "driver": "sqlite"
  }
}
```

## Statement Created

Fires whenever a statement is created. A statement is an individual SQL statement within a batch.

**Env**: `SQLPAD_WEBHOOK_STATEMENT_CREATED_URL`

**Payload Body**:

```json
{
  "action": "statement_created",
  "sqlpadUrl": "",
  "statement": {
    "id": "cce16e35-5b8f-442f-b7e8-e7fded25d02d",
    "batchId": "e11c9dde-53f8-4528-9b36-a889b90a70b7",
    "sequence": 1,
    "statementText": "SELECT 1 AS id, 'blue' AS color UNION ALL SELECT 2 AS id, 'red' AS color",
    "status": "queued",
    "startTime": null,
    "stopTime": null,
    "durationMs": null,
    "columns": null,
    "rowCount": null,
    "resultsPath": null,
    "incomplete": null,
    "error": null,
    "createdAt": "2020-07-19T22:31:09.050Z",
    "updatedAt": "2020-07-19T22:31:09.050Z"
  },
  "batch": {
    "id": "e11c9dde-53f8-4528-9b36-a889b90a70b7",
    "queryId": "81e68fcc-0876-449e-b2e6-03d4c087a054",
    "name": null,
    "connectionId": "e2b6a29d-4843-4062-8b12-ec1911979af5",
    "connectionClientId": null,
    "status": "started",
    "startTime": "2020-07-19T22:31:09.049Z",
    "stopTime": null,
    "durationMs": null,
    "batchText": "SELECT 1 AS id, 'blue' AS color UNION ALL SELECT 2 AS id, 'red' AS color",
    "selectedText": "SELECT 1 AS id, 'blue' AS color UNION ALL SELECT 2 AS id, 'red' AS color",
    "chart": null,
    "userId": "b478820a-1e0b-408f-8075-0f74baa74192",
    "createdAt": "2020-07-19T22:31:09.049Z",
    "updatedAt": "2020-07-19T22:31:09.049Z"
  },
  "user": {
    "id": "b478820a-1e0b-408f-8075-0f74baa74192",
    "name": null,
    "email": "admin@test.com",
    "role": "admin",
    "createdAt": "2020-07-19T22:31:08.704Z"
  },
  "connection": {
    "id": "e2b6a29d-4843-4062-8b12-ec1911979af5",
    "name": "test connection",
    "driver": "sqlite"
  }
}
```

## Statement Finished

Fires whenever a statement finishes running. A statement finished payload contains results for success, an error for failure

**Env**: `SQLPAD_WEBHOOK_STATEMENT_FINISHED_URL`

**Payload Body (success)**:

```json
{
  "action": "statement_finished",
  "sqlpadUrl": "",
  "statement": {
    "id": "ab466a92-0282-489b-9222-ff70ba9c846f",
    "batchId": "f35b21e7-1f96-49d0-a4ef-3a3e8e5f3266",
    "sequence": 1,
    "statementText": "SELECT 1 AS id, 'blue' AS color UNION ALL SELECT 2 AS id, 'red' AS color",
    "status": "finished",
    "startTime": "2020-07-19T22:26:06.826Z",
    "stopTime": "2020-07-19T22:26:06.831Z",
    "durationMs": 5,
    "columns": [
      {
        "datatype": "number",
        "max": 2,
        "min": 1,
        "maxValueLength": 0,
        "name": "id"
      },
      {
        "datatype": "string",
        "max": null,
        "min": null,
        "maxValueLength": 4,
        "name": "color"
      }
    ],
    "rowCount": 2,
    "resultsPath": "results\\ab4\\ab466a92-0282-489b-9222-ff70ba9c846f.json",
    "incomplete": false,
    "error": null,
    "createdAt": "2020-07-19T22:26:06.738Z",
    "updatedAt": "2020-07-19T22:26:06.835Z"
  },
  "batch": {
    "id": "f35b21e7-1f96-49d0-a4ef-3a3e8e5f3266",
    "queryId": "b53c73a9-79f9-4239-a6be-22fe1937b909",
    "name": null,
    "connectionId": "c9830ffb-a9f3-4e18-a040-5d4ae9011537",
    "connectionClientId": null,
    "status": "started",
    "startTime": "2020-07-19T22:26:06.736Z",
    "stopTime": null,
    "durationMs": null,
    "batchText": "SELECT 1 AS id, 'blue' AS color UNION ALL SELECT 2 AS id, 'red' AS color",
    "selectedText": "SELECT 1 AS id, 'blue' AS color UNION ALL SELECT 2 AS id, 'red' AS color",
    "chart": null,
    "userId": "2ff31b6d-4519-4e3f-912d-e6c54ad1d8c1",
    "createdAt": "2020-07-19T22:26:06.737Z",
    "updatedAt": "2020-07-19T22:26:06.737Z"
  },
  "user": {
    "id": "2ff31b6d-4519-4e3f-912d-e6c54ad1d8c1",
    "name": null,
    "email": "admin@test.com",
    "role": "admin",
    "createdAt": "2020-07-19T22:26:06.433Z"
  },
  "connection": {
    "id": "c9830ffb-a9f3-4e18-a040-5d4ae9011537",
    "name": "test connection",
    "driver": "sqlite"
  },
  "results": [
    [1, "blue"],
    [2, "red"]
  ]
}
```

**Payload Body (error)**:

```json
{
  "action": "statement_finished",
  "sqlpadUrl": "",
  "statement": {
    "id": "80f4ae01-e804-4962-8e78-239e32ba8d58",
    "batchId": "004df93e-4b78-4896-b929-4e3f9be850f9",
    "sequence": 1,
    "statementText": "SELECT 1 AS id, 'blue' AS color UNION ALL SELECT 2 AS id, 'red' AS color UNION ALL",
    "status": "error",
    "startTime": "2020-07-19T22:29:36.967Z",
    "stopTime": "2020-07-19T22:29:36.972Z",
    "durationMs": 5,
    "columns": null,
    "rowCount": null,
    "resultsPath": null,
    "incomplete": null,
    "error": {
      "title": "SQLITE_ERROR: incomplete input"
    },
    "createdAt": "2020-07-19T22:29:36.859Z",
    "updatedAt": "2020-07-19T22:29:36.973Z"
  },
  "batch": {
    "id": "004df93e-4b78-4896-b929-4e3f9be850f9",
    "queryId": "73d11915-0a8e-4a70-9b14-c11f4bc05573",
    "name": null,
    "connectionId": "ef19eae5-d91c-4d5c-abd9-19841d9f0250",
    "connectionClientId": null,
    "status": "error",
    "startTime": "2020-07-19T22:29:36.857Z",
    "stopTime": "2020-07-19T22:29:36.972Z",
    "durationMs": 5,
    "batchText": "SELECT 1 AS id, 'blue' AS color UNION ALL SELECT 2 AS id, 'red' AS color UNION ALL",
    "selectedText": "SELECT 1 AS id, 'blue' AS color UNION ALL SELECT 2 AS id, 'red' AS color UNION ALL",
    "chart": null,
    "userId": "7a2823c7-fb1e-41ed-841d-fd1ba0a0a1dc",
    "createdAt": "2020-07-19T22:29:36.858Z",
    "updatedAt": "2020-07-19T22:29:36.974Z"
  },
  "user": {
    "id": "7a2823c7-fb1e-41ed-841d-fd1ba0a0a1dc",
    "name": null,
    "email": "admin@test.com",
    "role": "admin",
    "createdAt": "2020-07-19T22:29:36.517Z"
  },
  "connection": {
    "id": "ef19eae5-d91c-4d5c-abd9-19841d9f0250",
    "name": "test connection",
    "driver": "sqlite"
  },
  "results": []
}
```



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3ffaab72..23398d34 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -554,7 +554,7 @@ Query result grid no longer has data bars for numeric values since it didn't mak
 - UI design updates _everywhere_
 - Query Listing:
   - preview query contents by hovering over query listing
-  - occassional search/filter weirdness has been fixed
+  - occasional search/filter weirdness has been fixed
 - Query Editor:
   - Schema sidebar no longer separates views and tables in hierarchy
   - New result grid
diff --git a/DEVELOPER-GUIDE.md b/DEVELOPER-GUIDE.md
index d4e2e346..9b091a62 100644
--- a/DEVELOPER-GUIDE.md
+++ b/DEVELOPER-GUIDE.md
@@ -47,7 +47,7 @@ cd server
 node server.js --config ./config.dev.env
 ```
 
-If prefered, SQLPad can be installed as a global module using the local files in this repo. This allows running SQLPad via the cli in any directory, just as if you had installed it with `npm install sqlpad -g`. Note that you must build and copy the client prior to this step.
+If preferred, SQLPad can be installed as a global module using the local files in this repo. This allows running SQLPad via the cli in any directory, just as if you had installed it with `npm install sqlpad -g`. Note that you must build and copy the client prior to this step.
 
 ```sh
 cd server
diff --git a/README.md b/README.md
index 9f9d7240..d0bab3ee 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ A web app for writing and running SQL queries and visualizing the results. Suppo
 
 The docker image runs on port 3000 and uses `/var/lib/sqlpad` for the embedded database directory.
 
-`latest` tag is continously built from latest commit in repo. Only use that if you want to live on the edge, otherwise use specific version tags to ensure stability.
+`latest` tag is continuously built from latest commit in repo. Only use that if you want to live on the edge, otherwise use specific version tags to ensure stability.
 
 See [docker-examples](https://github.com/rickbergfalk/sqlpad/tree/master/docker-examples) directory for example docker-compose setup with SQL Server.
 
diff --git a/client/src/css/vendorOverrides.css b/client/src/css/vendorOverrides.css
index d7ae27b4..d848491f 100644
--- a/client/src/css/vendorOverrides.css
+++ b/client/src/css/vendorOverrides.css
@@ -19,7 +19,7 @@
   transition: all 0.3s ease;
 }
 
-/* QueryResultDataTable react-window/react-draggable implementaion */
+/* QueryResultDataTable react-window/react-draggable implementation */
 .DragHandle {
   flex: 0 0 16px;
   z-index: 2;
diff --git a/docs/api-overview.md b/docs/api-overview.md
index e6bc8611..e2ec64e0 100644
--- a/docs/api-overview.md
+++ b/docs/api-overview.md
@@ -25,7 +25,7 @@ For `400` - `500` status codes, the body returned will be a JSON error object, w
 
 ## Example: List Items
 
-List APIs will return an array of objects. The objects in the list may be in summary form specifing only high-level information about the object.
+List APIs will return an array of objects. The objects in the list may be in summary form specifying only high-level information about the object.
 
 **200: Body with results**
 
diff --git a/docs/api-queries.md b/docs/api-queries.md
index bc3ba7d9..1bae7af7 100644
--- a/docs/api-queries.md
+++ b/docs/api-queries.md
@@ -126,7 +126,7 @@ Creating, updating, and fetching a single query returns the full query object
 
 ## Server Filtering and Pagination
 
-Queries may grow to a large number of entries. Use pagination and filtering to effeciently fetch intended queries.
+Queries may grow to a large number of entries. Use pagination and filtering to efficiently fetch intended queries.
 
 ### Example
 
diff --git a/docs/authentication.md b/docs/authentication.md
index a66372e4..fb1916a3 100644
--- a/docs/authentication.md
+++ b/docs/authentication.md
@@ -144,7 +144,7 @@ LDAP-based authentication can be enabled and used with local authencation togeth
 
 ## Allowed Domains for User Administration
 
-An entire domain can be allowed for username administration by setting enviornment variable `SQLPAD_ALLOWED_DOMAINS`. This may be particularly useful in combination with OAuth.
+An entire domain can be allowed for username administration by setting environment variable `SQLPAD_ALLOWED_DOMAINS`. This may be particularly useful in combination with OAuth.
 
 ## Service Token
 
diff --git a/docs/configuration.md b/docs/configuration.md
index ec4b9837..4b646181 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -101,7 +101,7 @@ If enabled, runs embedded database `nedb` in memory. In this case, the database
 
 ## backendDatabaseUri
 
-(Experimental) You can specify an external database to be used instead of the local sqlite database, by specifing a [Sequelize](https://sequelize.org/v5/) connection string. Supported databases are: mysql, mariadb, sqlite3, mssql. Some options can be provided in the connection string. Example: `mariadb://username:password@host:port/databasename?ssl=true`
+(Experimental) You can specify an external database to be used instead of the local sqlite database, by specifying a [Sequelize](https://sequelize.org/v5/) connection string. Supported databases are: mysql, mariadb, sqlite3, mssql. Some options can be provided in the connection string. Example: `mariadb://username:password@host:port/databasename?ssl=true`
 
 - Key: `backendDatabaseUri`
 - Env: `SQLPAD_BACKEND_DB_URI`
diff --git a/docs/connections.md b/docs/connections.md
index 4feeab75..fb1b90ac 100644
--- a/docs/connections.md
+++ b/docs/connections.md
@@ -10,7 +10,7 @@ Admins can create connections in the UI, but connections can also be created in
 
 ?> Available as of `4.2.0`
 
-Multi-statement transaction support adds the ability for a user to use the same underlying connection across query executions. This allows things like opening a transaction, running queries, and rolling the transaction back or comitting the transaction across query runs. It also opens up the ability to create and use temp tables that are generally scoped per connection session.
+Multi-statement transaction support adds the ability for a user to use the same underlying connection across query executions. This allows things like opening a transaction, running queries, and rolling the transaction back or committing the transaction across query runs. It also opens up the ability to create and use temp tables that are generally scoped per connection session.
 
 Multi-statement transaction support is opt-in based on connection configuration. If a connection uses a driver and multi-statement transaction support is not enabled, the connection falls back to the legacy SQLPad behavior of opening a new connection for each query execution, then immediately closing it following the query.
 
@@ -66,6 +66,9 @@ A default connection selection can be set using environment variable `SQLPAD_DEF
     <tr><td>driver</td><td>Must be <code>crate</code></td><td>text</td></tr>
     <tr><td>host</td><td>Host/Server/IP Address</td><td>text</td></tr>
     <tr><td>port</td><td>Port (optional)</td><td>text</td></tr>
+    <tr><td>username</td><td>Database Username</td><td>text</td></tr>
+    <tr><td>password</td><td>Database Password</td><td>text</td></tr>
+    <tr><td>ssl</td><td>Use SSL</td><td>boolean</td></tr>
   </tbody>
 </table>
 
diff --git a/docs/getting-started.md b/docs/getting-started.md
index e8b0c9b5..3bf691b2 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -14,7 +14,7 @@ The docker image runs on port 3000 by default and stores its local database file
 
 SQLPad tries to follow semantic versioning. As an application, this primarily means breaking HTTP API changes, breaking configuration changes, or major UI design changes will result in a major version bump. Minor and patch version bumps will consist of enhancements and fixes.
 
-The `latest` tag on Docker Hub is continously built from latest commit from the `master` branch in GitHub. Do not use it unless you are okay experiencing a work-in-progress. It should be functional, but may not be stable or final.
+The `latest` tag on Docker Hub is continuously built from latest commit from the `master` branch in GitHub. Do not use it unless you are okay experiencing a work-in-progress. It should be functional, but may not be stable or final.
 
 ## Updating SQLPad
 
diff --git a/docs/seed-data.md b/docs/seed-data.md
index a38f238a..8fb2d8c4 100644
--- a/docs/seed-data.md
+++ b/docs/seed-data.md
@@ -51,7 +51,7 @@ Queries are created or replaced matching on query id. At this time the query ACL
 
 The `acl` entries work in a similar way, but are explicit about field names. If providing the email address of a user that has access to the query, use `userEmail`. Reference the SQLPad userId in the `userId` field.
 
-Example seed query JSON file (comments only added for documentaion purposes):
+Example seed query JSON file (comments only added for documentation purposes):
 
 ```js
 {
diff --git a/docs/webhooks.md b/docs/webhooks.md
new file mode 100644
index 00000000..e64b1900
--- /dev/null
+++ b/docs/webhooks.md
@@ -0,0 +1,406 @@
+# Webhooks
+
+?> Available `5.2.0`
+
+A variety of webhooks are available to extend the functionality of SQLPad.
+
+To enable a specific webhook, set `SQLPAD_WEBHOOK_ENABLED` to `true`, and provide a URL for the event you would like to subscribe to. HTTP and HTTPS URLs are supported. This URL will be sent an HTTP POST with a payload of relevant information when the event occurs.
+
+To ensure a webhook call is valid, `SQLPAD_WEBHOOK_SECRET` may be configured, and validated on every webhook call.
+
+The webhook POST will contain the following headers:
+
+- `Content-Type`: `application/json`
+- `SQLPad-Secret`: `SQLPAD_WEBHOOK_SECRET value`
+
+The request body sent to the URL varies by event. Every body will have an `action` field with the name of the action, as well as `sqlpadUrl`, which will contain the URL to the SQLPad instance that sent the action (if `PUBLIC_URL` is configured).
+
+## User Created
+
+The user created webhook is sent whenever a user is added via the users API/UI or auto user creation (if enabled and supported via authentication configured). This hook can be used to automate user invites, sending a message to users inviting them to sign up (if using local user/password authentication) or sign in (if using a single sign on).
+
+**Env**: `SQLPAD_WEBHOOK_USER_CREATED_URL`
+
+**Payload Body**:
+
+```json
+{
+  "action": "user_created",
+  "sqlpadUrl": "http://mysqlpad.com:9000/sqlpad",
+  "user": {
+    "id": "a57acb9e-859e-44b7-83a8-42c5cbb809ce",
+    "name": "user1",
+    "email": "user1@test.com",
+    "role": "editor",
+    "createdAt": "2020-07-19T18:54:18.858Z"
+  }
+}
+```
+
+## Query Created
+
+Fires whenever a query is created (initial query save). This is not fired on query execution (see batch created).
+
+**Env**: `SQLPAD_WEBHOOK_QUERY_CREATED_URL`
+
+**Payload Body**:
+
+```json
+{
+  "action": "query_created",
+  "sqlpadUrl": "http://mysqlpad.com:9000/sqlpad",
+  "query": {
+    "id": "d0eb347e-864a-4058-bcb2-5456adf5474f",
+    "name": "test query",
+    "queryText": "SELECT * FROM some_table",
+    "tags": ["one", "two"],
+    "chart": null,
+    "createdByUser": {
+      "id": "92185834-42d3-4b63-b0d4-6d81e5e83dfd",
+      "name": null,
+      "email": "admin@test.com"
+    },
+    "createdAt": "2020-07-19T18:57:14.522Z"
+  }
+}
+```
+
+## Batch Created
+
+Fires whenever a batch is created. A batch is one or more queries, and is the API used to execute queries in the UI.
+
+**Env**: `SQLPAD_WEBHOOK_BATCH_CREATED_URL`
+
+**Payload Body**:
+
+```json
+{
+  "action": "batch_created",
+  "sqlpadUrl": "",
+  "batch": {
+    "id": "c169c7fc-0b9b-482a-8fe6-c9b3486d2f88",
+    "queryId": "2f3e9bb6-cadf-4d52-a0c0-43089353193f",
+    "name": null,
+    "connectionId": "6ffeeb1d-1142-4004-b9f8-91cef0d15850",
+    "connectionClientId": null,
+    "status": "started",
+    "startTime": "2020-07-19T19:01:09.980Z",
+    "stopTime": null,
+    "durationMs": null,
+    "batchText": "SELECT 1 AS id, 'blue' AS color",
+    "selectedText": "SELECT 1 AS id, 'blue' AS color",
+    "chart": null,
+    "userId": "50076232-f06f-4a80-8a4d-fc6b8b265830",
+    "createdAt": "2020-07-19T19:01:09.980Z",
+    "updatedAt": "2020-07-19T19:01:09.980Z",
+    "statements": [
+      {
+        "id": "9fc69c47-8fda-4d7d-90a7-8236733e4dbe",
+        "batchId": "c169c7fc-0b9b-482a-8fe6-c9b3486d2f88",
+        "sequence": 1,
+        "statementText": "SELECT 1 AS id, 'blue' AS color",
+        "status": "queued",
+        "startTime": null,
+        "stopTime": null,
+        "durationMs": null,
+        "columns": null,
+        "rowCount": null,
+        "resultsPath": null,
+        "incomplete": null,
+        "error": null,
+        "createdAt": "2020-07-19T19:01:09.981Z",
+        "updatedAt": "2020-07-19T19:01:09.981Z"
+      }
+    ]
+  },
+  "user": {
+    "id": "50076232-f06f-4a80-8a4d-fc6b8b265830",
+    "name": null,
+    "email": "admin@test.com",
+    "role": "admin",
+    "createdAt": "2020-07-19T19:01:09.594Z"
+  },
+  "connection": {
+    "id": "6ffeeb1d-1142-4004-b9f8-91cef0d15850",
+    "name": "test connection",
+    "driver": "sqlite"
+  }
+}
+```
+
+## Batch Finished
+
+Fires whenever a batch finished running. Payload contains summary information for success, or first error on failure. To receive results, use statement finished hook.
+
+**Env**: `SQLPAD_WEBHOOK_BATCH_FINISHED_URL`
+
+**Payload Body**:
+
+```js
+{
+  "action": "batch_finished",
+  "sqlpadUrl": "",
+  "batch": {
+    "id": "40cf0e10-2bd1-4a02-92f8-340a99775671",
+    "queryId": "026ca49e-491e-4520-9ead-76bcdb287d28",
+    "name": null,
+    "connectionId": "668489b8-6049-43a8-a7af-0f34abf1984c",
+    "connectionClientId": null,
+    "status": "finished",
+    "startTime": "2020-07-19T19:01:57.696Z",
+    "stopTime": "2020-07-19T19:01:57.830Z",
+    "durationMs": 15,
+    "batchText": "SELECT 1 AS id, 'blue' AS color",
+    "selectedText": "SELECT 1 AS id, 'blue' AS color",
+    "chart": null,
+    "userId": "d49295f7-29b8-4ba5-be10-218638d02af7",
+    "createdAt": "2020-07-19T19:01:57.696Z",
+    "updatedAt": "2020-07-19T19:01:57.830Z",
+    "statements": [
+      {
+        "id": "396e8043-c766-4fec-b441-82dbcdf16473",
+        "batchId": "40cf0e10-2bd1-4a02-92f8-340a99775671",
+        "sequence": 1,
+        "statementText": "SELECT 1 AS id, 'blue' AS color",
+        "status": "finished",
+        "startTime": "2020-07-19T19:01:57.815Z",
+        "stopTime": "2020-07-19T19:01:57.821Z",
+        "durationMs": 6,
+        "columns": [
+          {
+            "datatype": "number",
+            "max": 1,
+            "min": 1,
+            "maxValueLength": 0,
+            "name": "id"
+          },
+          {
+            "datatype": "string",
+            "max": null,
+            "min": null,
+            "maxValueLength": 4,
+            "name": "color"
+          }
+        ],
+        "rowCount": 1,
+        "resultsPath": "results\\396\\396e8043-c766-4fec-b441-82dbcdf16473.json",
+        "incomplete": false,
+        "error": null,
+        "createdAt": "2020-07-19T19:01:57.697Z",
+        "updatedAt": "2020-07-19T19:01:57.828Z"
+      }
+    ]
+  },
+  "user": {
+    "id": "d49295f7-29b8-4ba5-be10-218638d02af7",
+    "name": null,
+    "email": "admin@test.com",
+    "role": "admin",
+    "createdAt": "2020-07-19T19:01:57.325Z"
+  },
+  "connection": {
+    "id": "668489b8-6049-43a8-a7af-0f34abf1984c",
+    "name": "test connection",
+    "driver": "sqlite"
+  }
+}
+```
+
+## Statement Created
+
+Fires whenever a statement is created. A statement is an individual SQL statement within a batch.
+
+**Env**: `SQLPAD_WEBHOOK_STATEMENT_CREATED_URL`
+
+**Payload Body**:
+
+```json
+{
+  "action": "statement_created",
+  "sqlpadUrl": "",
+  "statement": {
+    "id": "cce16e35-5b8f-442f-b7e8-e7fded25d02d",
+    "batchId": "e11c9dde-53f8-4528-9b36-a889b90a70b7",
+    "sequence": 1,
+    "statementText": "SELECT 1 AS id, 'blue' AS color UNION ALL SELECT 2 AS id, 'red' AS color",
+    "status": "queued",
+    "startTime": null,
+    "stopTime": null,
+    "durationMs": null,
+    "columns": null,
+    "rowCount": null,
+    "resultsPath": null,
+    "incomplete": null,
+    "error": null,
+    "createdAt": "2020-07-19T22:31:09.050Z",
+    "updatedAt": "2020-07-19T22:31:09.050Z"
+  },
+  "batch": {
+    "id": "e11c9dde-53f8-4528-9b36-a889b90a70b7",
+    "queryId": "81e68fcc-0876-449e-b2e6-03d4c087a054",
+    "name": null,
+    "connectionId": "e2b6a29d-4843-4062-8b12-ec1911979af5",
+    "connectionClientId": null,
+    "status": "started",
+    "startTime": "2020-07-19T22:31:09.049Z",
+    "stopTime": null,
+    "durationMs": null,
+    "batchText": "SELECT 1 AS id, 'blue' AS color UNION ALL SELECT 2 AS id, 'red' AS color",
+    "selectedText": "SELECT 1 AS id, 'blue' AS color UNION ALL SELECT 2 AS id, 'red' AS color",
+    "chart": null,
+    "userId": "b478820a-1e0b-408f-8075-0f74baa74192",
+    "createdAt": "2020-07-19T22:31:09.049Z",
+    "updatedAt": "2020-07-19T22:31:09.049Z"
+  },
+  "user": {
+    "id": "b478820a-1e0b-408f-8075-0f74baa74192",
+    "name": null,
+    "email": "admin@test.com",
+    "role": "admin",
+    "createdAt": "2020-07-19T22:31:08.704Z"
+  },
+  "connection": {
+    "id": "e2b6a29d-4843-4062-8b12-ec1911979af5",
+    "name": "test connection",
+    "driver": "sqlite"
+  }
+}
+```
+
+## Statement Finished
+
+Fires whenever a statement finishes running. A statement finished payload contains results for success, an error for failure
+
+**Env**: `SQLPAD_WEBHOOK_STATEMENT_FINISHED_URL`
+
+**Payload Body (success)**:
+
+```json
+{
+  "action": "statement_finished",
+  "sqlpadUrl": "",
+  "statement": {
+    "id": "ab466a92-0282-489b-9222-ff70ba9c846f",
+    "batchId": "f35b21e7-1f96-49d0-a4ef-3a3e8e5f3266",
+    "sequence": 1,
+    "statementText": "SELECT 1 AS id, 'blue' AS color UNION ALL SELECT 2 AS id, 'red' AS color",
+    "status": "finished",
+    "startTime": "2020-07-19T22:26:06.826Z",
+    "stopTime": "2020-07-19T22:26:06.831Z",
+    "durationMs": 5,
+    "columns": [
+      {
+        "datatype": "number",
+        "max": 2,
+        "min": 1,
+        "maxValueLength": 0,
+        "name": "id"
+      },
+      {
+        "datatype": "string",
+        "max": null,
+        "min": null,
+        "maxValueLength": 4,
+        "name": "color"
+      }
+    ],
+    "rowCount": 2,
+    "resultsPath": "results\\ab4\\ab466a92-0282-489b-9222-ff70ba9c846f.json",
+    "incomplete": false,
+    "error": null,
+    "createdAt": "2020-07-19T22:26:06.738Z",
+    "updatedAt": "2020-07-19T22:26:06.835Z"
+  },
+  "batch": {
+    "id": "f35b21e7-1f96-49d0-a4ef-3a3e8e5f3266",
+    "queryId": "b53c73a9-79f9-4239-a6be-22fe1937b909",
+    "name": null,
+    "connectionId": "c9830ffb-a9f3-4e18-a040-5d4ae9011537",
+    "connectionClientId": null,
+    "status": "started",
+    "startTime": "2020-07-19T22:26:06.736Z",
+    "stopTime": null,
+    "durationMs": null,
+    "batchText": "SELECT 1 AS id, 'blue' AS color UNION ALL SELECT 2 AS id, 'red' AS color",
+    "selectedText": "SELECT 1 AS id, 'blue' AS color UNION ALL SELECT 2 AS id, 'red' AS color",
+    "chart": null,
+    "userId": "2ff31b6d-4519-4e3f-912d-e6c54ad1d8c1",
+    "createdAt": "2020-07-19T22:26:06.737Z",
+    "updatedAt": "2020-07-19T22:26:06.737Z"
+  },
+  "user": {
+    "id": "2ff31b6d-4519-4e3f-912d-e6c54ad1d8c1",
+    "name": null,
+    "email": "admin@test.com",
+    "role": "admin",
+    "createdAt": "2020-07-19T22:26:06.433Z"
+  },
+  "connection": {
+    "id": "c9830ffb-a9f3-4e18-a040-5d4ae9011537",
+    "name": "test connection",
+    "driver": "sqlite"
+  },
+  "results": [
+    [1, "blue"],
+    [2, "red"]
+  ]
+}
+```
+
+**Payload Body (error)**:
+
+```json
+{
+  "action": "statement_finished",
+  "sqlpadUrl": "",
+  "statement": {
+    "id": "80f4ae01-e804-4962-8e78-239e32ba8d58",
+    "batchId": "004df93e-4b78-4896-b929-4e3f9be850f9",
+    "sequence": 1,
+    "statementText": "SELECT 1 AS id, 'blue' AS color UNION ALL SELECT 2 AS id, 'red' AS color UNION ALL",
+    "status": "error",
+    "startTime": "2020-07-19T22:29:36.967Z",
+    "stopTime": "2020-07-19T22:29:36.972Z",
+    "durationMs": 5,
+    "columns": null,
+    "rowCount": null,
+    "resultsPath": null,
+    "incomplete": null,
+    "error": {
+      "title": "SQLITE_ERROR: incomplete input"
+    },
+    "createdAt": "2020-07-19T22:29:36.859Z",
+    "updatedAt": "2020-07-19T22:29:36.973Z"
+  },
+  "batch": {
+    "id": "004df93e-4b78-4896-b929-4e3f9be850f9",
+    "queryId": "73d11915-0a8e-4a70-9b14-c11f4bc05573",
+    "name": null,
+    "connectionId": "ef19eae5-d91c-4d5c-abd9-19841d9f0250",
+    "connectionClientId": null,
+    "status": "error",
+    "startTime": "2020-07-19T22:29:36.857Z",
+    "stopTime": "2020-07-19T22:29:36.972Z",
+    "durationMs": 5,
+    "batchText": "SELECT 1 AS id, 'blue' AS color UNION ALL SELECT 2 AS id, 'red' AS color UNION ALL",
+    "selectedText": "SELECT 1 AS id, 'blue' AS color UNION ALL SELECT 2 AS id, 'red' AS color UNION ALL",
+    "chart": null,
+    "userId": "7a2823c7-fb1e-41ed-841d-fd1ba0a0a1dc",
+    "createdAt": "2020-07-19T22:29:36.858Z",
+    "updatedAt": "2020-07-19T22:29:36.974Z"
+  },
+  "user": {
+    "id": "7a2823c7-fb1e-41ed-841d-fd1ba0a0a1dc",
+    "name": null,
+    "email": "admin@test.com",
+    "role": "admin",
+    "createdAt": "2020-07-19T22:29:36.517Z"
+  },
+  "connection": {
+    "id": "ef19eae5-d91c-4d5c-abd9-19841d9f0250",
+    "name": "test connection",
+    "driver": "sqlite"
+  },
+  "results": []
+}
+```
diff --git a/server/app.js b/server/app.js
index fba1cb99..17c70f48 100644
--- a/server/app.js
+++ b/server/app.js
@@ -6,6 +6,7 @@ const pino = require('pino');
 const session = require('express-session');
 const FileStore = require('session-file-store')(session);
 const appLog = require('./lib/app-log');
+const Webhooks = require('./lib/webhooks.js');
 const bodyParser = require('body-parser');
 const favicon = require('serve-favicon');
 const passport = require('passport');
@@ -37,6 +38,8 @@ async function makeApp(config, models) {
     throw new Error('models is required to create app');
   }
 
+  const webhooks = new Webhooks(config, models, appLog);
+
   const expressPino = expressPinoLogger({
     level: config.get('webLogLevel'),
     timestamp: pino.stdTimeFunctions.isoTime,
@@ -72,6 +75,7 @@ async function makeApp(config, models) {
     req.config = config;
     req.models = models;
     req.appLog = appLog;
+    req.webhooks = webhooks;
 
     res.utils = new ResponseUtils(res, next);
 
diff --git a/server/auth-strategies/auth-proxy.js b/server/auth-strategies/auth-proxy.js
index 371c8b2a..99876f96 100644
--- a/server/auth-strategies/auth-proxy.js
+++ b/server/auth-strategies/auth-proxy.js
@@ -15,7 +15,7 @@ const appLog = require('../lib/app-log');
  */
 async function authProxyStrategy(req, done) {
   try {
-    const { config, models } = req;
+    const { config, models, webhooks } = req;
 
     const headerUser = getHeaderUser(req);
 
@@ -80,6 +80,7 @@ async function authProxyStrategy(req, done) {
       const newUser = await models.users.create({
         ...headerUser,
       });
+      webhooks.userCreated(newUser);
       return done(null, newUser);
     }
 
diff --git a/server/auth-strategies/google.js b/server/auth-strategies/google.js
index 925dce6e..e2253626 100644
--- a/server/auth-strategies/google.js
+++ b/server/auth-strategies/google.js
@@ -10,7 +10,7 @@ async function passportGoogleStrategyHandler(
   profile,
   done
 ) {
-  const { models, config } = req;
+  const { models, config, webhooks } = req;
   const email = profile && profile._json && profile._json.email;
 
   if (!email) {
@@ -42,6 +42,7 @@ async function passportGoogleStrategyHandler(
         role: openAdminRegistration ? 'admin' : 'editor',
         signupAt: new Date(),
       });
+      webhooks.userCreated(newUser);
       return done(null, newUser);
     }
     // at this point we don't have an error, but authentication is invalid
diff --git a/server/auth-strategies/oidc.js b/server/auth-strategies/oidc.js
index 4a45421a..8444c1b8 100644
--- a/server/auth-strategies/oidc.js
+++ b/server/auth-strategies/oidc.js
@@ -12,7 +12,7 @@ async function passportOidcStrategyHandler(
   refreshToken,
   done
 ) {
-  const { models, config, appLog } = req;
+  const { models, config, appLog, webhooks } = req;
   const _json = profile._json || {};
 
   // _json.sub appears to be an id. Is it? Should .sub be used for SQLPad user id?
@@ -55,6 +55,7 @@ async function passportOidcStrategyHandler(
         role: openAdminRegistration ? 'admin' : 'editor',
         signupAt: new Date(),
       });
+      webhooks.userCreated(newUser);
       appLog.debug(`OIDC User ${email} created`);
       return done(null, newUser);
     }
diff --git a/server/auth-strategies/saml.js b/server/auth-strategies/saml.js
index 454a2047..28167d83 100644
--- a/server/auth-strategies/saml.js
+++ b/server/auth-strategies/saml.js
@@ -37,7 +37,7 @@ function enableSaml(config) {
           }
           appLog.info('User attempts log in via SAML %s', email);
 
-          const { models } = req;
+          const { models, webhooks } = req;
 
           let [openAdminRegistration, user] = await Promise.all([
             models.users.adminRegistrationOpen(),
@@ -65,7 +65,7 @@ function enableSaml(config) {
                   config.get('samlDefaultRole_d'),
               signupAt: new Date(),
             });
-
+            webhooks.userCreated(newUser);
             return done(null, newUser);
           }
 
diff --git a/server/drivers/crate/index.js b/server/drivers/crate/index.js
index c3c61b8d..9d726d7e 100644
--- a/server/drivers/crate/index.js
+++ b/server/drivers/crate/index.js
@@ -47,11 +47,8 @@ async function runQuery(query, connection) {
   const { maxRows } = connection;
   const limit = maxRows < CRATE_LIMIT ? maxRows : CRATE_LIMIT;
 
-  if (connection.port) {
-    crate.connect(connection.host, connection.port);
-  } else {
-    crate.connect(connection.host);
-  }
+  let connectionString = getConnectionString(connection);
+  crate.connect(connectionString);
 
   try {
     const res = await crate.execute(query);
@@ -69,6 +66,37 @@ async function runQuery(query, connection) {
   }
 }
 
+/**
+ * Get connection string with options
+ * @param {*} connection
+ */
+function getConnectionString(connection) {
+  let url;
+  try {
+    // if connection.host contains a valid url e.g. 'https://crate.io:4200'
+    url = new URL(connection.host);
+    // if connection.host doesn't contain protocol e.g. 'crate.io:4200'
+    if (!['http:', 'https:'].includes(url.protocol)) {
+      let protocol = connection.ssl ? 'https:' : 'http:';
+      url = new URL(protocol + connection.host);
+    }
+  } catch (error) {
+    // if connection.host contains only hostname e.g. 'crate.io'
+    let protocol = connection.ssl ? 'https:' : 'http:';
+    url = new URL(protocol + connection.host);
+  }
+  if (connection.port) {
+    url.port = connection.port;
+  }
+  if (connection.username) {
+    url.username = connection.username;
+    if (connection.password) {
+      url.password = connection.password;
+    }
+  }
+  return url.href;
+}
+
 /**
  * Test connectivity of connection
  * @param {*} connection
@@ -106,6 +134,21 @@ const fields = [
     formType: 'TEXT',
     label: 'Port (optional)',
   },
+  {
+    key: 'username',
+    formType: 'TEXT',
+    label: 'Database Username',
+  },
+  {
+    key: 'password',
+    formType: 'PASSWORD',
+    label: 'Database Password',
+  },
+  {
+    key: 'ssl',
+    formType: 'CHECKBOX',
+    label: 'Use SSL',
+  },
 ];
 
 module.exports = {
diff --git a/server/lib/config/config-items.js b/server/lib/config/config-items.js
index a54d623b..2ac30d06 100644
--- a/server/lib/config/config-items.js
+++ b/server/lib/config/config-items.js
@@ -478,6 +478,46 @@ const configItems = [
     envVar: 'SQLPAD_OIDC_LINK_HTML',
     default: 'Sign in with OpenID',
   },
+  {
+    key: 'webhookEnabled',
+    envVar: 'SQLPAD_WEBHOOK_ENABLED',
+    default: false,
+  },
+  {
+    key: 'webhookSecret',
+    envVar: 'SQLPAD_WEBHOOK_SECRET',
+    default: '',
+  },
+  {
+    key: 'webhookUserCreatedUrl',
+    envVar: 'SQLPAD_WEBHOOK_USER_CREATED_URL',
+    default: '',
+  },
+  {
+    key: 'webhookQueryCreatedUrl',
+    envVar: 'SQLPAD_WEBHOOK_QUERY_CREATED_URL',
+    default: '',
+  },
+  {
+    key: 'webhookBatchCreatedUrl',
+    envVar: 'SQLPAD_WEBHOOK_BATCH_CREATED_URL',
+    default: '',
+  },
+  {
+    key: 'webhookBatchFinishedUrl',
+    envVar: 'SQLPAD_WEBHOOK_BATCH_FINISHED_URL',
+    default: '',
+  },
+  {
+    key: 'webhookStatementCreatedUrl',
+    envVar: 'SQLPAD_WEBHOOK_STATEMENT_CREATED_URL',
+    default: '',
+  },
+  {
+    key: 'webhookStatementFinishedUrl',
+    envVar: 'SQLPAD_WEBHOOK_STATEMENT_FINISHED_URL',
+    default: '',
+  },
 ];
 
 module.exports = configItems;
diff --git a/server/lib/execute-batch.js b/server/lib/execute-batch.js
index e7775c77..f91bc191 100644
--- a/server/lib/execute-batch.js
+++ b/server/lib/execute-batch.js
@@ -6,9 +6,10 @@ const ConnectionClient = require('./connection-client');
  * Batch must already be created.
  * @param {Object} config
  * @param {import('../models/index')} models
+ * @param {import('./webhooks')} webhooks
  * @param {string} batchId
  */
-async function executeBatch(config, models, batchId) {
+async function executeBatch(config, models, webhooks, batchId) {
   const batch = await models.batches.findOneById(batchId);
   const user = await models.users.findOneById(batch.userId);
   const connection = await models.connections.findOneById(batch.connectionId);
@@ -52,6 +53,7 @@ async function executeBatch(config, models, batchId) {
         stopTime,
         stopTime - statementStartTime
       );
+      webhooks.statementFinished(user, connection, batch, statement.id);
     } catch (error) {
       statementError = error;
       stopTime = new Date();
@@ -65,22 +67,25 @@ async function executeBatch(config, models, batchId) {
         stopTime - statementStartTime
       );
 
-      await models.batches.update(batch.id, {
+      const updatedBatch = await models.batches.update(batch.id, {
         status: 'error',
         stopTime,
         durationMs: stopTime - batchStartTime,
       });
+      webhooks.statementFinished(user, connection, updatedBatch, statement.id);
+      webhooks.batchFinished(user, connection, updatedBatch);
       break;
     }
   }
   stopTime = new Date();
 
   if (!statementError) {
-    await models.batches.update(batch.id, {
+    const updatedBatch = await models.batches.update(batch.id, {
       status: 'finished',
       stopTime,
       durationMs: stopTime - batchStartTime,
     });
+    webhooks.batchFinished(user, connection, updatedBatch);
   }
 
   if (disconnectOnFinish) {
diff --git a/server/lib/webhooks.js b/server/lib/webhooks.js
new file mode 100644
index 00000000..25e92533
--- /dev/null
+++ b/server/lib/webhooks.js
@@ -0,0 +1,215 @@
+const fetch = require('node-fetch');
+const appLog = require('./app-log');
+
+function userSummary(user) {
+  if (!user) {
+    return;
+  }
+  const { id, name, email, role, createdAt } = user;
+  return { id, name, email, role, createdAt };
+}
+
+function connectionSummary(connection) {
+  if (!connection) {
+    return;
+  }
+  return {
+    id: connection.id,
+    name: connection.name,
+    driver: connection.driver,
+  };
+}
+
+class Webhooks {
+  /**
+   *
+   * @param {import('./config')} config
+   * @param {import('../models')} models
+   * @param {import('./app-log')} appLog
+   */
+  constructor(config, models, appLog) {
+    this.config = config;
+    this.models = models;
+    this.appLog = appLog;
+  }
+
+  hookEnabledUrl(hookConfigKeyName) {
+    return (
+      this.config.get('webhookEnabled') && this.config.get(hookConfigKeyName)
+    );
+  }
+
+  sqlpadUrl() {
+    const { config } = this;
+    const port = parseInt(config.get('port'), 10);
+    const publicUrl = config.get('publicUrl') || '';
+    const baseUrl = config.get('baseUrl');
+
+    if (!publicUrl) {
+      return '';
+    }
+
+    const usingDefaultPort =
+      (publicUrl.startsWith('https:') && port === 443) ||
+      (publicUrl.startsWith('http:') && port === 80);
+
+    const urlPort = !usingDefaultPort ? `:${port}` : '';
+
+    return `${publicUrl}${urlPort}${baseUrl}`;
+  }
+
+  async send(hookName, url, body) {
+    const { config, appLog } = this;
+    try {
+      const res = await fetch(url, {
+        method: 'POST',
+        body: JSON.stringify({
+          action: hookName,
+          sqlpadUrl: this.sqlpadUrl(),
+          ...body,
+        }),
+        headers: {
+          'Content-Type': 'application/json',
+          'SQLPad-Secret': config.get('webhookSecret'),
+        },
+      });
+
+      if (!res.ok) {
+        appLog.error(
+          {
+            url,
+            body,
+            status: res.status,
+            statusText: res.statusText,
+          },
+          `Error sending webhook`
+        );
+      }
+    } catch (error) {
+      appLog.error({ url, body, error }, `Error sending webhook`);
+    }
+  }
+
+  userCreated(user) {
+    const url = this.hookEnabledUrl('webhookUserCreatedUrl');
+    if (!url) {
+      return;
+    }
+
+    const body = {
+      user: userSummary(user),
+    };
+
+    return this.send('user_created', url, body);
+  }
+
+  queryCreated(query, connection) {
+    const url = this.hookEnabledUrl('webhookQueryCreatedUrl');
+    if (!url) {
+      return;
+    }
+
+    const {
+      id,
+      name,
+      queryText,
+      tags,
+      chart,
+      createdByUser,
+      createdAt,
+    } = query;
+
+    const body = {
+      query: {
+        id,
+        name,
+        queryText,
+        tags,
+        chart,
+        createdByUser,
+        createdAt,
+      },
+      connection: connectionSummary(connection),
+    };
+
+    return this.send('query_created', url, body);
+  }
+
+  batchCreated(user, connection, batch) {
+    const url = this.hookEnabledUrl('webhookBatchCreatedUrl');
+    if (!url) {
+      return;
+    }
+
+    const body = {
+      batch,
+      user: userSummary(user),
+      connection: connectionSummary(connection),
+    };
+
+    return this.send('batch_created', url, body);
+  }
+
+  batchFinished(user, connection, batch) {
+    const url = this.hookEnabledUrl('webhookBatchFinishedUrl');
+    if (!url) {
+      return;
+    }
+
+    const body = {
+      batch,
+      user: userSummary(user),
+      connection: connectionSummary(connection),
+    };
+
+    return this.send('batch_finished', url, body);
+  }
+
+  async statementCreated(user, connection, batch, statement) {
+    const url = this.hookEnabledUrl('webhookStatementCreatedUrl');
+    if (!url) {
+      return;
+    }
+
+    const { statements, ...batchWithoutStatements } = batch;
+
+    const body = {
+      statement,
+      batch: batchWithoutStatements,
+      user: userSummary(user),
+      connection: connectionSummary(connection),
+    };
+
+    return this.send('statement_created', url, body);
+  }
+
+  async statementFinished(user, connection, batch, statementId) {
+    const url = this.hookEnabledUrl('webhookStatementFinishedUrl');
+    if (!url) {
+      return;
+    }
+
+    try {
+      const { statements, ...batchWithoutStatements } = batch;
+
+      const statement = await this.models.statements.findOneById(statementId);
+      const results = await this.models.statements.getStatementResults(
+        statementId
+      );
+
+      const body = {
+        statement,
+        batch: batchWithoutStatements,
+        user: userSummary(user),
+        connection: connectionSummary(connection),
+        results,
+      };
+
+      return this.send('statement_finished', url, body);
+    } catch (error) {
+      appLog.error(error, 'error sending statement created webhook');
+    }
+  }
+}
+
+module.exports = Webhooks;
diff --git a/server/routes/batches.js b/server/routes/batches.js
index 4101c021..a7c28b71 100644
--- a/server/routes/batches.js
+++ b/server/routes/batches.js
@@ -11,7 +11,7 @@ const mustHaveConnectionAccess = require('../middleware/must-have-connection-acc
  * @param {Res} res
  */
 async function create(req, res) {
-  const { config, models, body, user, appLog } = req;
+  const { config, models, body, user, appLog, webhooks } = req;
   const {
     queryId,
     name,
@@ -33,12 +33,18 @@ async function create(req, res) {
     userId: user.id,
   };
 
+  const connection = await models.connections.findOneById(connectionId);
   const newBatch = await models.batches.create(batch);
 
+  webhooks.batchCreated(user, connection, newBatch);
+  for (const statement of newBatch.statements) {
+    webhooks.statementCreated(user, connection, newBatch, statement);
+  }
+
   // Run batch, but don't wait for it to send response
   // Client will get status via polling or perhaps some future event mechanism
   if (newBatch.status !== 'error') {
-    executeBatch(config, models, newBatch.id).catch((error) =>
+    executeBatch(config, models, webhooks, newBatch.id).catch((error) =>
       appLog.error(error)
     );
   }
diff --git a/server/routes/queries.js b/server/routes/queries.js
index 8ca6e6d2..08382e49 100644
--- a/server/routes/queries.js
+++ b/server/routes/queries.js
@@ -299,7 +299,7 @@ router.get('/api/queries/:id', mustBeAuthenticated, wrap(getQuery));
  * @param {Res} res
  */
 async function createQuery(req, res) {
-  const { models, body, user } = req;
+  const { models, body, user, webhooks } = req;
   const { name, tags, connectionId, queryText, chart, acl } = body;
 
   const query = {
@@ -315,6 +315,13 @@ async function createQuery(req, res) {
 
   const newQuery = await models.upsertQuery(query);
 
+  let connection;
+  if (connectionId) {
+    connection = await models.connections.findOneById(connectionId);
+  }
+
+  webhooks.queryCreated(newQuery, connection);
+
   // This is async, but save operation doesn't care about when/if finished
   pushQueryToSlack(req.config, newQuery, user);
 
diff --git a/server/routes/signin.js b/server/routes/signin.js
index 3d3a10f9..b4deaf48 100644
--- a/server/routes/signin.js
+++ b/server/routes/signin.js
@@ -5,7 +5,7 @@ require('../typedefs');
 
 /**
  * Try to determine the form of login, and authenticate the request
- * Requests calling this route directly will have a session created and a correpsonding cookie
+ * Requests calling this route directly will have a session created and a corresponding cookie
  * This is unlike the more passive sessionless authentication middleware,
  * which will authenticate without starting a session
  * @param {Req} req
diff --git a/server/routes/signup.js b/server/routes/signup.js
index 30a8ae03..5913eb56 100644
--- a/server/routes/signup.js
+++ b/server/routes/signup.js
@@ -10,7 +10,7 @@ const wrap = require('../lib/wrap');
  * @param {Function} next
  */
 async function handleSignup(req, res, next) {
-  const { models, config } = req;
+  const { models, config, webhooks } = req;
 
   if (config.get('userpassAuthDisabled') || config.get('disableUserpassAuth')) {
     return res.utils.forbidden();
@@ -51,6 +51,7 @@ async function handleSignup(req, res, next) {
       role: adminRegistrationOpen ? 'admin' : 'editor',
       signupAt: new Date(),
     });
+    webhooks.userCreated(user);
     return next();
   } else {
     return res.utils.forbidden();
diff --git a/server/routes/users.js b/server/routes/users.js
index 1c3dca70..2c0056bb 100644
--- a/server/routes/users.js
+++ b/server/routes/users.js
@@ -29,7 +29,7 @@ async function listUsers(req, res) {
  * @param {Res} res
  */
 async function createUser(req, res) {
-  const { models, appLog } = req;
+  const { models, appLog, webhooks } = req;
 
   let user = await models.users.findOneByEmail(req.body.email);
   if (user) {
@@ -44,6 +44,8 @@ async function createUser(req, res) {
     data: req.body.data,
   });
 
+  webhooks.userCreated(user);
+
   const email = makeEmail(req.config);
 
   if (req.config.smtpConfigured()) {
diff --git a/server/sequelize-db/batches.js b/server/sequelize-db/batches.js
index 39370969..af5aab5c 100644
--- a/server/sequelize-db/batches.js
+++ b/server/sequelize-db/batches.js
@@ -27,7 +27,7 @@ module.exports = function (sequelize) {
         type: DataTypes.STRING,
         validate: {
           // Potentially add 'cancelled'?
-          // We have no way of cancelling current statement but future could be
+          // We have no way of canceling current statement but future could be
           isIn: [['started', 'finished', 'error']],
         },
         defaultValue: 'started',
diff --git a/server/typedefs.js b/server/typedefs.js
index b4b64ab5..c49a3b56 100644
--- a/server/typedefs.js
+++ b/server/typedefs.js
@@ -5,6 +5,7 @@
  * @property {Object} log - Pino web logger. Same as appLog but no setLevel
  * @property {import('./lib/logger')} appLog - Pino app logger
  * @property {import('./models')} models - Collection of data access objects
+ * @property {import('./lib/webhooks')} webhooks - Webhook utility instance
  */
 
 /**


Test patch:
diff --git a/server/drivers/sqlite/test.js b/server/drivers/sqlite/test.js
index c5200da2..ac0a038e 100644
--- a/server/drivers/sqlite/test.js
+++ b/server/drivers/sqlite/test.js
@@ -31,12 +31,16 @@ describe('drivers/sqlite', function () {
     assert(schemaInfo.main);
     assert(schemaInfo.main.sqlpad_test, 'main.sqlpad_test');
     const columns = schemaInfo.main.sqlpad_test;
-    assert.equal(columns.length, 1, 'columns.length');
+    assert.equal(columns.length, 2, 'columns.length');
     assert.equal(columns[0].table_schema, 'main', 'table_schema');
     assert.equal(columns[0].table_name, 'sqlpad_test', 'table_name');
-    // column metadata not available in sqlite3
-    assert.equal(columns[0].column_name, 'unknown', 'column_name');
-    assert.equal(columns[0].data_type, 'unknown', 'data_type');
+    assert.equal(columns[0].column_name, 'id', 'column_name');
+    assert.equal(columns[0].data_type, 'INTEGER', 'data_type');
+
+    assert.equal(columns[1].table_schema, 'main', 'table_schema');
+    assert.equal(columns[1].table_name, 'sqlpad_test', 'table_name');
+    assert.equal(columns[1].column_name, 'name', 'column_name');
+    assert.equal(columns[1].data_type, 'TEXT', 'data_type');
   });
 
   it('runQuery under limit', async function () {
@@ -58,17 +62,6 @@ describe('drivers/sqlite', function () {
     assert.equal(results.rows.length, 2, 'row length');
   });
 
-  it('Runs multiple statements', async function () {
-    const query = `
-      SELECT id FROM sqlpad_test;
-      SELECT name FROM sqlpad_test;
-      SELECT * FROM sqlpad_test WHERE id = 2
-    `;
-    const results = await sqlite3.runQuery(query, connection);
-    assert.strictEqual(results.incomplete, false);
-    assert.equal(results.rows.length, 7, 'row length');
-  });
-
   it('Throws helpful error', async function () {
     let error;
     try {
diff --git a/server/test/api/webhooks.js b/server/test/api/webhooks.js
new file mode 100644
index 00000000..7fbb7c3b
--- /dev/null
+++ b/server/test/api/webhooks.js
@@ -0,0 +1,228 @@
+/* eslint-disable no-await-in-loop */
+const assert = require('assert');
+const TestUtils = require('../utils');
+
+/**
+ * Logs body to a JSON file. Useful for debug/generating JSON examples for documentation
+ * @param {object} body
+ */
+// eslint-disable-next-line no-unused-vars
+function logBody(body) {
+  const fs = require('fs');
+  const path = require('path');
+  fs.writeFileSync(
+    path.join(__dirname, '/webhook.json'),
+    JSON.stringify(body, null, 2),
+    'utf8'
+  );
+}
+
+function wait(ms) {
+  return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+describe('api/webhooks', function () {
+  it('does not fire if not enabled', async function () {
+    const hookServer = await TestUtils.makeHookServer();
+
+    const utils = new TestUtils({
+      port: 9000,
+      publicUrl: 'http://mysqlpad.com',
+      baseUrl: '/sqlpad',
+      webhookSecret: 'secret',
+      webhookUserCreatedUrl: hookServer.url,
+    });
+    await utils.init(true);
+
+    await utils.post('admin', '/sqlpad/api/users', {
+      email: 'user1@test.com',
+      name: 'user1',
+      role: 'editor',
+      data: {
+        create: true,
+      },
+    });
+
+    await wait(200);
+    assert.equal(hookServer.responses.length, 0);
+  });
+
+  it('userCreated', async function () {
+    const hookServer = await TestUtils.makeHookServer();
+
+    const utils = new TestUtils({
+      port: 9000,
+      publicUrl: 'http://mysqlpad.com',
+      baseUrl: '/sqlpad',
+      webhookEnabled: true,
+      webhookSecret: 'secret',
+      webhookUserCreatedUrl: hookServer.url,
+    });
+    await utils.init(true);
+
+    const user = await utils.post('admin', '/sqlpad/api/users', {
+      email: 'user1@test.com',
+      name: 'user1',
+      role: 'editor',
+    });
+
+    await wait(200);
+
+    assert.deepStrictEqual(hookServer.responses[0].body, {
+      action: 'user_created',
+      sqlpadUrl: 'http://mysqlpad.com:9000/sqlpad',
+      user: {
+        id: user.id,
+        name: user.name,
+        role: user.role,
+        email: user.email,
+        createdAt: user.createdAt,
+      },
+    });
+
+    // Only need to test this once
+    // Ensure headers are sent as expected
+    assert.equal(hookServer.responses[0].headers['sqlpad-secret'], 'secret');
+    hookServer.server.close();
+  });
+
+  it('queryCreated', async function () {
+    const hookServer = await TestUtils.makeHookServer();
+    const utils = new TestUtils({
+      webhookEnabled: true,
+      webhookQueryCreatedUrl: hookServer.url,
+    });
+    await utils.init(true);
+
+    const connection = await utils.post('admin', '/api/connections', {
+      name: 'test connection',
+      driver: 'sqlite',
+      data: {
+        filename: './test/fixtures/sales.sqlite',
+      },
+    });
+
+    const queryWithoutCon = await utils.post('admin', '/api/queries', {
+      name: 'test query',
+      tags: ['one', 'two'],
+      queryText: 'SELECT * FROM some_table',
+    });
+
+    await utils.post('admin', '/api/queries', {
+      name: 'test query 2',
+      tags: ['one', 'two'],
+      connectionId: connection.id,
+      queryText: 'SELECT * FROM some_table',
+    });
+
+    await wait(200);
+
+    // no secret or url headers this time
+    assert.equal(hookServer.responses[0].headers['sqlpad-secret'], '');
+
+    const body1 = hookServer.responses[0].body;
+    assert.equal(body1.action, 'query_created');
+    assert.equal(body1.sqlpadUrl, '');
+    assert.equal(body1.query.id, queryWithoutCon.id, 'query r1');
+    assert.equal(body1.query.name, queryWithoutCon.name);
+    assert.deepStrictEqual(body1.query.tags, queryWithoutCon.tags);
+    assert.equal(body1.query.queryText, queryWithoutCon.queryText);
+    assert.equal(body1.query.createdAt, queryWithoutCon.createdAt);
+    assert.deepEqual(body1.query.createdByUser, queryWithoutCon.createdByUser);
+    assert(!body1.connection);
+
+    const body2 = hookServer.responses[1].body;
+    assert.equal(body2.connection.id, connection.id, 'connection r2');
+    assert.equal(body2.connection.name, connection.name);
+    assert.equal(body2.connection.driver, connection.driver);
+
+    hookServer.server.close();
+  });
+
+  it('batchCreated / batchFinished / statementCreated / statementFinished', async function () {
+    const hookServer = await TestUtils.makeHookServer();
+    const utils = new TestUtils({
+      webhookEnabled: true,
+      webhookBatchCreatedUrl: hookServer.url,
+      webhookBatchFinishedUrl: hookServer.url,
+      webhookStatementCreatedUrl: hookServer.url,
+      webhookStatementFinishedUrl: hookServer.url,
+    });
+    await utils.init(true);
+
+    const connection = await utils.post('admin', '/api/connections', {
+      name: 'test connection',
+      driver: 'sqlite',
+      data: {
+        filename: './test/fixtures/sales.sqlite',
+      },
+    });
+
+    const queryText = `SELECT 1 AS id, 'blue' AS color UNION ALL SELECT 2 AS id, 'red' AS color`;
+    const query = await utils.post('admin', '/api/queries', {
+      name: 'test query',
+      tags: ['test'],
+      connectionId: connection.id,
+      queryText,
+    });
+
+    let batch = await utils.post('admin', `/api/batches`, {
+      connectionId: connection.id,
+      queryId: query.id,
+      batchText: queryText,
+      selectedText: queryText,
+    });
+
+    batch = await utils.get('admin', `/api/batches/${batch.id}`);
+    while (batch.status !== 'finished' && batch.status !== 'error') {
+      await wait(50);
+      batch = await utils.get('admin', `/api/batches/${batch.id}`);
+    }
+
+    const statements = await utils.get(
+      'admin',
+      `/api/batches/${batch.id}/statements`
+    );
+    assert.equal(statements.length, 1);
+
+    const statement1 = statements[0];
+
+    await wait(500);
+
+    const { body: batchCreatedBody } = hookServer.responses.find(
+      (r) => r.body.action === 'batch_created'
+    );
+    assert.equal(batchCreatedBody.batch.id, batch.id);
+    assert.equal(batchCreatedBody.user.id, utils.users.admin.id);
+
+    const { body: batchFinishedBody } = hookServer.responses.find(
+      (r) => r.body.action === 'batch_finished'
+    );
+    assert.equal(batchFinishedBody.batch.id, batch.id);
+    assert.equal(batchFinishedBody.user.id, utils.users.admin.id);
+
+    const { body: statementCreatedBody } = hookServer.responses.find(
+      (r) => r.body.action === 'statement_created'
+    );
+    assert.equal(statementCreatedBody.statement.id, statement1.id);
+    assert.equal(statementCreatedBody.user.id, utils.users.admin.id);
+    assert.equal(statementCreatedBody.batch.id, batch.id);
+    assert.equal(statementCreatedBody.connection.id, connection.id);
+
+    const { body: statementFinishedBody } = hookServer.responses.find(
+      (r) => r.body.action === 'statement_finished'
+    );
+    assert.equal(statementFinishedBody.statement.id, statement1.id);
+    assert.deepEqual(statementFinishedBody.results, [
+      [1, 'blue'],
+      [2, 'red'],
+    ]);
+    assert.equal(statementFinishedBody.user.id, utils.users.admin.id);
+    assert.equal(statementFinishedBody.batch.id, batch.id);
+    assert.equal(statementFinishedBody.connection.id, connection.id);
+
+    // Ensure connection does not have sensitive data
+    assert(!statementFinishedBody.connection.data);
+    assert(!statementFinishedBody.connection.filename);
+  });
+});
diff --git a/server/test/utils.js b/server/test/utils.js
index 1c1750f0..7250e67a 100644
--- a/server/test/utils.js
+++ b/server/test/utils.js
@@ -4,6 +4,10 @@ const rimraf = require('rimraf');
 const mkdirp = require('mkdirp');
 const path = require('path');
 const request = require('supertest');
+const detectPort = require('detect-port');
+const bodyParser = require('body-parser');
+const express = require('express');
+const http = require('http');
 const Config = require('../lib/config');
 const { Sequelize } = require('sequelize');
 const appLog = require('../lib/app-log');
@@ -70,6 +74,31 @@ class TestUtils {
     };
   }
 
+  static async makeHookServer() {
+    const responses = [];
+    const port = await detectPort(4000);
+    const app = express();
+    app.use(bodyParser.json());
+    app.post('/', function (req, res) {
+      responses.push({
+        headers: {
+          'sqlpad-secret': req.get('sqlpad-secret'),
+          'sqlpad-url': req.get('sqlpad-url'),
+          'sqlpad-hook-name': req.get('sqlpad-hook-name'),
+        },
+        body: req.body,
+      });
+      res.json({});
+    });
+
+    const server = http.createServer(app).listen(port);
+    return {
+      server,
+      url: `http://localhost:${port}/`,
+      responses,
+    };
+  }
+
   static randomize_dbname(uri) {
     if (!uri) return '';
     const salt = uuidv4().replace(/-/g, '');