New FastAPI python page for easily human reading of the json objects we are capturing. Addition of a template parser that can be used to interpret and send/push jobMaterials to the server. Adding the docs dir just so that it is all together.

This commit is contained in:
2026-04-28 16:14:07 +10:00
parent 9ab1b3ea8f
commit debc442081
11 changed files with 2794 additions and 0 deletions
+1
View File
@@ -3,3 +3,4 @@ include
lib
lib64
__pycache__
servicem8_webhooks.db
BIN
View File
Binary file not shown.
+20
View File
@@ -0,0 +1,20 @@
### Form response identifiers
Form response from "Quote Template" has UUID - 3621b6be-1d19-4756-9ab4-9d5e4120f6d9
Form response from “job safety analysis” has uuid= “2ab961e2-e029-4aee-8f6e-200b1e865b8b”
Form response from “OSSM Maintenance Report” - “5dfaeff1-362f-4aec-bd36-eed54168fc09”
At this time we are only interested in the response UUID for the "Quote Templates"
There is a nested JSON element called "jobMaterials" within in each "job" (our target object) - each jobMaterial has their own uuid which appears to tbe system created - but we need to create each jobMaterial element using the SDK or REST API from the "Quote Template" "form" details.
##### JSON UUIDs for jobMaterials (Destination field types for the Quote Templates inside the "Job" object")
- all “QUOTE INCLUDE” headers are “uuid” - “b752046f-2543-40a8-82bf-241f035a6c3d”
- top level object has material_uuid = “1924893b-917f-474a-adaa-2093bd622d4b”
- all “QUOTE INCLUDES” subitems have a “material_uuid” = “8c00ca29-2178-403e-be76-241cfaddeedb”
- all QUOTE EXCLUDES headers are “uuid” - “5e9aeda9-2c59-43db-ba64-241f0b7812bd”
- top level object has material_uuid = “4947bfd7-4875-48f7-9caf-2093b9751b9b”
- all “QUOTE EXCLUDES” subitems have a “material_uuid” = “8c00ca29-2178-403e-be76-241cfaddeedb”
+461
View File
@@ -0,0 +1,461 @@
# Retrieve a Job Material
#### OAuth Scope
This endpoint requires the following OAuth scope **read_job_materials**.
# OpenAPI definition
```json
{
"openapi": "3.1.0",
"info": {
"title": "ServiceM8 API",
"description": "Move your app forward with the ServiceM8 API\n\n\n\n## Limits and Throttling\nTo ensure continuous quality of service, API usage can be subject to throttling. The throttle will be applied once an API consumer reaches a certain \nthreshold in terms of a maximum number of requests per minute. Most clients will never hit this threshold, but those that do, will get met by a \nHTTP 429 Too Many Requests response code. \n \nThere is a limit of 180 requests per minute, if you reach this you will receive a HTTP 429 with a text body of \"Number of allowed API requests per minute exceeded\".\nThere is a limit of 20000 requests per day, if you reach this you will receive a HTTP 429 with a text body of \"Number of allowed API requests per day exceeded\".\n\nWe encourage all API developers to anticipate this error, and take appropriate measures like e.g. using a cached value from a previous call, or passing on a message to the end user that gets subjected to this behaviour (if any).\n\nLimits are per Addon per account.\n",
"termsOfService": "https://www.servicem8.com/terms-of-service",
"version": "1.0.0"
},
"servers": [
{
"url": "https://api.servicem8.com/api_1.0"
}
],
"security": [
{
"apiKey": []
},
{
"oauth2": []
}
],
"paths": {
"/jobmaterial/{uuid}.json": {
"get": {
"tags": [
"Job Materials"
],
"operationId": "getJobMaterials",
"summary": "Retrieve a Job Material",
"description": "\n\t\t\t\n#### OAuth Scope\nThis endpoint requires the following OAuth scope **read_job_materials**.\n\n\t\t\t",
"security": [
{
"apiKey": []
},
{
"oauth2": [
"read_job_materials"
]
}
],
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the Job Material",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "Job Material record retrieved successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/JobMaterial"
},
"examples": {
"success": {
"value": {
"uuid": "123e4567-74e3-4f64-b22f-23f94bbf232b",
"active": 1,
"edit_date": "2026-03-01 12:00:00",
"job_uuid": "123e4567-baf5-48ae-813e-23f9401800cb",
"material_uuid": "123e4567-f19c-4dc5-8229-23f948ed0d6b",
"name": "string",
"quantity": "string",
"price": "string",
"displayed_amount": "string",
"displayed_amount_is_tax_inclusive": "string",
"tax_rate_uuid": "123e4567-dbcd-485a-8ae8-23f946e8852b",
"sort_order": "string",
"cost": "string",
"displayed_cost": "string",
"job_material_bundle_uuid": "123e4567-6328-4b87-baa7-23f948a1827b"
}
}
}
}
}
},
"400": {
"description": "Bad Request - The request is malformed or contains invalid parameters",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
},
"examples": {
"badRequest": {
"value": {
"errorCode": "1000",
"message": "An error occurred completing your request"
}
}
}
}
}
},
"401": {
"description": "Unauthorized - Authentication credentials are missing or invalid",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AuthenticationError"
},
"examples": {
"unauthorized": {
"value": {
"errorCode": "401",
"message": "Authentication failed. Please check your API key or OAuth token."
}
}
}
}
}
},
"403": {
"description": "Forbidden - You don't have permission to access this resource",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ForbiddenError"
},
"examples": {
"forbidden": {
"value": {
"errorCode": "403",
"message": "Access forbidden. You don't have permission to access this resource."
}
}
}
}
}
},
"404": {
"description": "Not Found - The requested record does not exist or has been deleted",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotFoundError"
},
"examples": {
"notFound": {
"value": {
"errorCode": "404",
"message": "Resource not found. The requested record does not exist or has been deleted."
}
}
}
}
}
},
"429": {
"description": "Too Many Requests - You have exceeded the rate limit",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RateLimitError"
},
"examples": {
"rateLimitMinute": {
"value": {
"errorCode": 429,
"message": "Number of allowed API requests per minute exceeded"
}
},
"rateLimitDay": {
"value": {
"errorCode": 429,
"message": "Number of allowed API requests per day exceeded"
}
}
}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred on the server",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
},
"examples": {
"serverError": {
"value": {
"errorCode": 500,
"message": "An unexpected error occurred. Please try again later."
}
}
}
}
}
}
}
}
}
},
"components": {
"securitySchemes": {
"apiKey": {
"type": "apiKey",
"name": "X-Api-Key",
"in": "header"
},
"oauth2": {
"type": "oauth2",
"flows": {
"authorizationCode": {
"authorizationUrl": "https://api.servicem8.com/oauth/authorize",
"tokenUrl": "https://api.servicem8.com/oauth/access_token",
"scopes": {
"staff_locations": "Access to real-time GPS information about staff",
"staff_activity": "Access to clock on, lunch break and clock off information about staff",
"publish_sms": "Access to send SMS messages to customers and/or staff on your behalf. Note sending SMS messages will incur account charges.",
"publish_email": "Access to send Email messages to customers and/or staff on your behalf",
"vendor": "Access to basic account information",
"vendor_logo": "Access to account logo",
"vendor_email": "Access to account holder email address",
"read_locations": "Read-only access to Location Endpoint",
"manage_locations": "Full access to Location Endpoint",
"read_staff": "Read-only access to Staff Endpoint",
"manage_staff": "Full access to Staff Endpoint",
"read_customers": "Read-only access to Company Endpoint",
"manage_customers": "Full access to Company Endpoint",
"read_customer_contacts": "Read-only access to CompanyContact Endpoint",
"manage_customer_contacts": "Full access to CompanyContact Endpoint",
"read_jobs": "Read-only access to Job Endpoint",
"manage_jobs": "Full access to Job Endpoint",
"create_jobs": "Ability to create jobs on behalf of account. Note creating jobs may incur account charges.",
"read_job_contacts": "Read-only access to JobContact Endpoint",
"manage_job_contacts": "Full access to JobContact Endpoint",
"read_job_materials": "Read-only access to JobMaterials Endpoint",
"manage_job_materials": "Full access to JobMaterials Endpoint",
"read_job_categories": "Read-only access to Categories Endpoint",
"manage_job_categories": "Full access to Categories Endpoint",
"read_job_queues": "Read-only access to Job Queues Endpoint",
"manage_job_queues": "Full access to Job Queues Endpoint",
"read_tasks": "Read-only access to Tasks Endpoint",
"manage_tasks": "Full access to Tasks Endpoint",
"read_schedule": "Read-only access to JobActivity Endpoint",
"manage_schedule": "Full access to JobActivity Endpoint",
"read_inventory": "Read-only access to Materials Endpoint",
"manage_inventory": "Full access to Materials Endpoint",
"read_job_notes": "Read-only access to job notes",
"publish_job_notes": "Ability to add new job notes",
"read_job_photos": "Read-only access to job photos",
"publish_job_photos": "Ability to add new job photos",
"read_attachments": "Read-only access to Attachments Endpoint",
"manage_attachments": "Full access to Attachments Endpoint",
"read_inbox": "Read-only access to inbox messages",
"read_messages": "Read-only access to staff messages",
"manage_notifications": "Ability to read notifications and mark as read",
"manage_templates": "Full-access to email, sms and document templates",
"manage_badges": "Full-access to create/modify job badges",
"read_assets": "Read-only access to Assets Endpoint",
"manage_assets": "Full access to Assets Endpoint",
"read_knowledge_base": "Read-only access to Knowledge Base Endpoint",
"manage_knowledge_base": "Full access to Knowledge Base Endpoint"
}
}
}
}
},
"schemas": {
"Error": {
"type": "object",
"properties": {
"errorCode": {
"type": "number",
"format": "int32",
"example": "1000"
},
"message": {
"type": "string",
"example": "An error occurred completing your request"
}
}
},
"RateLimitError": {
"type": "object",
"properties": {
"errorCode": {
"type": "number",
"format": "int32",
"example": "429"
},
"message": {
"type": "string",
"example": "Number of allowed API requests per minute exceeded"
}
}
},
"AuthenticationError": {
"type": "object",
"properties": {
"errorCode": {
"type": "number",
"format": "int32",
"example": "401"
},
"message": {
"type": "string",
"example": "Authentication failed. Please check your API key or OAuth token."
}
}
},
"ForbiddenError": {
"type": "object",
"properties": {
"errorCode": {
"type": "number",
"format": "int32",
"example": "403"
},
"message": {
"type": "string",
"example": "Access forbidden. You don't have permission to access this resource."
}
}
},
"NotFoundError": {
"type": "object",
"properties": {
"errorCode": {
"type": "number",
"format": "int32",
"example": "404"
},
"message": {
"type": "string",
"example": "Resource not found. The requested record does not exist or has been deleted."
}
}
},
"JobMaterial": {
"type": "object",
"properties": {
"job_uuid": {
"description": "The UUID of the job this material is associated with. This is a required field that establishes the relationship between the job material and its parent job.",
"format": "uuid",
"example": "123e4567-9539-4f05-ba2e-23f943147fab",
"type": "string"
},
"material_uuid": {
"description": "The UUID of the material catalog item this job material is based on. Links the job material to the corresponding material in the materials catalog.",
"format": "uuid",
"example": "123e4567-6664-44df-8cc2-23f94cbf8a9b",
"type": "string"
},
"name": {
"description": "The name of the material item used on the job. This is displayed on invoices and is used to identify the material to the customer. The name typically comes from the associated material object but can be customized per job.",
"type": "string",
"maxLength": 500
},
"quantity": {
"description": "The quantity of this material used on the job. This field is mandatory and cannot be empty.",
"type": "string",
"maxLength": 100
},
"price": {
"description": "The unit price of the material excluding tax. Used in calculations to determine the total price for this line item on the job. The system may automatically adjust this value to maintain consistency with tax-inclusive pricing.",
"type": "string"
},
"displayed_amount": {
"description": "The unit price amount as displayed on invoices and quotes. This can be either tax-inclusive or tax-exclusive depending on the displayed_amount_is_tax_inclusive field value. Used for presentation to customers.",
"type": "string"
},
"displayed_amount_is_tax_inclusive": {
"description": "Boolean flag indicating whether the displayed_amount includes tax (true) or excludes tax (false). This controls how prices are presented to customers and determines which price value (inclusive or exclusive) is used in calculations.",
"type": "string"
},
"tax_rate_uuid": {
"description": "The UUID of the tax rate applied to this job material. Determines how tax is calculated for this specific line item.",
"format": "uuid",
"example": "123e4567-a746-4be2-bd00-23f949815b5b",
"type": "string"
},
"sort_order": {
"description": "Integer value controlling the display order of materials on a job. Lower values appear first in lists. Used to customize the presentation order of materials on quotes, invoices and job forms.",
"type": "string"
},
"cost": {
"description": "The cost of the material for this job. This is the ex-tax amount.",
"type": "string"
},
"displayed_cost": {
"description": "The cost of the material for this job, displayed as inc-tax or ex-tax depending on jobMaterial.displayed_amount_is_tax_inclusive.",
"type": "string"
},
"uuid": {
"format": "uuid",
"description": "Unique identifier for this record",
"example": "123e4567-be6b-4a92-a2d6-23f94483836b",
"type": "string"
},
"active": {
"enum": [
0,
1
],
"type": "integer",
"default": 1,
"description": "Record active/deleted flag. Valid values are [0,1]"
},
"edit_date": {
"example": "2026-03-01 12:00:00",
"readOnly": true,
"description": "Timestamp at which record was last modified"
},
"job_material_bundle_uuid": {
"format": "uuid",
"example": "123e4567-50da-46fd-9a5e-23f941ffeacb",
"type": "string",
"description": "UUID of a JobMaterialBundle which this JobMaterial belongs to. The default value is blank, which means that the JobMaterial is not part of a JobMaterialBundle."
}
},
"required": [
"quantity"
]
}
}
},
"x-speakeasy-retries": {
"strategy": "backoff",
"backoff": {
"initialInterval": 500,
"maxInterval": 60000,
"maxElapsedTime": 3600000,
"exponent": 1.5
},
"statusCodes": [
"5XX",
"429"
],
"retryConnectionErrors": true
},
"tags": [
{
"name": "Job Materials",
"description": "Operations related to Job Materials"
}
]
}
```
+430
View File
@@ -0,0 +1,430 @@
# Create a new Job Material
#### OAuth Scope
This endpoint requires the following OAuth scope **manage_job_materials**.
#### Record UUID
UUID is optional for record creation. If no UUID is supplied, a UUID will be automatically generated for the new record and returned in the `x-record-uuid` response header.
# OpenAPI definition
```json
{
"openapi": "3.1.0",
"info": {
"title": "ServiceM8 API",
"description": "Move your app forward with the ServiceM8 API\n\n\n\n## Limits and Throttling\nTo ensure continuous quality of service, API usage can be subject to throttling. The throttle will be applied once an API consumer reaches a certain \nthreshold in terms of a maximum number of requests per minute. Most clients will never hit this threshold, but those that do, will get met by a \nHTTP 429 Too Many Requests response code. \n \nThere is a limit of 180 requests per minute, if you reach this you will receive a HTTP 429 with a text body of \"Number of allowed API requests per minute exceeded\".\nThere is a limit of 20000 requests per day, if you reach this you will receive a HTTP 429 with a text body of \"Number of allowed API requests per day exceeded\".\n\nWe encourage all API developers to anticipate this error, and take appropriate measures like e.g. using a cached value from a previous call, or passing on a message to the end user that gets subjected to this behaviour (if any).\n\nLimits are per Addon per account.\n",
"termsOfService": "https://www.servicem8.com/terms-of-service",
"version": "1.0.0"
},
"servers": [
{
"url": "https://api.servicem8.com/api_1.0"
}
],
"security": [
{
"apiKey": []
},
{
"oauth2": []
}
],
"paths": {
"/jobmaterial.json": {
"post": {
"tags": [
"Job Materials"
],
"operationId": "createJobMaterials",
"summary": "Create a new Job Material",
"description": "\n\t\t\t\n#### OAuth Scope\nThis endpoint requires the following OAuth scope **manage_job_materials**.\n\n\t\t\t\n\t\t\t\n#### Record UUID\nUUID is optional for record creation. If no UUID is supplied, a UUID will be automatically generated for the new record and returned in the `x-record-uuid` response header.\n\n\t\t\t",
"security": [
{
"apiKey": []
},
{
"oauth2": [
"manage_job_materials"
]
}
],
"parameters": [],
"requestBody": {
"description": "Job Material record to create",
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/JobMaterialCreate"
}
}
}
},
"responses": {
"200": {
"headers": {
"x-record-uuid": {
"description": "UUID of newly created Job Material record",
"schema": {
"type": "string",
"format": "uuid"
}
}
},
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Result"
},
"examples": {
"success": {
"value": {
"errorCode": "0",
"message": "OK"
}
}
}
}
}
},
"400": {
"description": "Bad Request - The request is malformed or contains invalid parameters",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
},
"examples": {
"badRequest": {
"value": {
"errorCode": "1000",
"message": "An error occurred completing your request"
}
}
}
}
}
},
"401": {
"description": "Unauthorized - Authentication credentials are missing or invalid",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AuthenticationError"
},
"examples": {
"unauthorized": {
"value": {
"errorCode": "401",
"message": "Authentication failed. Please check your API key or OAuth token."
}
}
}
}
}
},
"403": {
"description": "Forbidden - You don't have permission to create this resource",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ForbiddenError"
},
"examples": {
"forbidden": {
"value": {
"errorCode": "403",
"message": "Access forbidden. You don't have permission to access this resource."
}
}
}
}
}
},
"429": {
"description": "Too Many Requests - You have exceeded the rate limit",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RateLimitError"
},
"examples": {
"rateLimitMinute": {
"value": {
"errorCode": 429,
"message": "Number of allowed API requests per minute exceeded"
}
},
"rateLimitDay": {
"value": {
"errorCode": 429,
"message": "Number of allowed API requests per day exceeded"
}
}
}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred on the server",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
},
"examples": {
"serverError": {
"value": {
"errorCode": 500,
"message": "An unexpected error occurred. Please try again later."
}
}
}
}
}
}
}
}
}
},
"components": {
"securitySchemes": {
"apiKey": {
"type": "apiKey",
"name": "X-Api-Key",
"in": "header"
},
"oauth2": {
"type": "oauth2",
"flows": {
"authorizationCode": {
"authorizationUrl": "https://api.servicem8.com/oauth/authorize",
"tokenUrl": "https://api.servicem8.com/oauth/access_token",
"scopes": {
"staff_locations": "Access to real-time GPS information about staff",
"staff_activity": "Access to clock on, lunch break and clock off information about staff",
"publish_sms": "Access to send SMS messages to customers and/or staff on your behalf. Note sending SMS messages will incur account charges.",
"publish_email": "Access to send Email messages to customers and/or staff on your behalf",
"vendor": "Access to basic account information",
"vendor_logo": "Access to account logo",
"vendor_email": "Access to account holder email address",
"read_locations": "Read-only access to Location Endpoint",
"manage_locations": "Full access to Location Endpoint",
"read_staff": "Read-only access to Staff Endpoint",
"manage_staff": "Full access to Staff Endpoint",
"read_customers": "Read-only access to Company Endpoint",
"manage_customers": "Full access to Company Endpoint",
"read_customer_contacts": "Read-only access to CompanyContact Endpoint",
"manage_customer_contacts": "Full access to CompanyContact Endpoint",
"read_jobs": "Read-only access to Job Endpoint",
"manage_jobs": "Full access to Job Endpoint",
"create_jobs": "Ability to create jobs on behalf of account. Note creating jobs may incur account charges.",
"read_job_contacts": "Read-only access to JobContact Endpoint",
"manage_job_contacts": "Full access to JobContact Endpoint",
"read_job_materials": "Read-only access to JobMaterials Endpoint",
"manage_job_materials": "Full access to JobMaterials Endpoint",
"read_job_categories": "Read-only access to Categories Endpoint",
"manage_job_categories": "Full access to Categories Endpoint",
"read_job_queues": "Read-only access to Job Queues Endpoint",
"manage_job_queues": "Full access to Job Queues Endpoint",
"read_tasks": "Read-only access to Tasks Endpoint",
"manage_tasks": "Full access to Tasks Endpoint",
"read_schedule": "Read-only access to JobActivity Endpoint",
"manage_schedule": "Full access to JobActivity Endpoint",
"read_inventory": "Read-only access to Materials Endpoint",
"manage_inventory": "Full access to Materials Endpoint",
"read_job_notes": "Read-only access to job notes",
"publish_job_notes": "Ability to add new job notes",
"read_job_photos": "Read-only access to job photos",
"publish_job_photos": "Ability to add new job photos",
"read_attachments": "Read-only access to Attachments Endpoint",
"manage_attachments": "Full access to Attachments Endpoint",
"read_inbox": "Read-only access to inbox messages",
"read_messages": "Read-only access to staff messages",
"manage_notifications": "Ability to read notifications and mark as read",
"manage_templates": "Full-access to email, sms and document templates",
"manage_badges": "Full-access to create/modify job badges",
"read_assets": "Read-only access to Assets Endpoint",
"manage_assets": "Full access to Assets Endpoint",
"read_knowledge_base": "Read-only access to Knowledge Base Endpoint",
"manage_knowledge_base": "Full access to Knowledge Base Endpoint"
}
}
}
}
},
"schemas": {
"Result": {
"type": "object",
"properties": {
"errorCode": {
"type": "number",
"format": "int32",
"example": "0"
},
"message": {
"type": "string",
"example": "OK"
}
}
},
"Error": {
"type": "object",
"properties": {
"errorCode": {
"type": "number",
"format": "int32",
"example": "1000"
},
"message": {
"type": "string",
"example": "An error occurred completing your request"
}
}
},
"RateLimitError": {
"type": "object",
"properties": {
"errorCode": {
"type": "number",
"format": "int32",
"example": "429"
},
"message": {
"type": "string",
"example": "Number of allowed API requests per minute exceeded"
}
}
},
"AuthenticationError": {
"type": "object",
"properties": {
"errorCode": {
"type": "number",
"format": "int32",
"example": "401"
},
"message": {
"type": "string",
"example": "Authentication failed. Please check your API key or OAuth token."
}
}
},
"ForbiddenError": {
"type": "object",
"properties": {
"errorCode": {
"type": "number",
"format": "int32",
"example": "403"
},
"message": {
"type": "string",
"example": "Access forbidden. You don't have permission to access this resource."
}
}
},
"JobMaterialCreate": {
"type": "object",
"properties": {
"job_uuid": {
"description": "The UUID of the job this material is associated with. This is a required field that establishes the relationship between the job material and its parent job.",
"format": "uuid",
"example": "123e4567-6110-4f36-93cc-23f94736b95b",
"type": "string"
},
"material_uuid": {
"description": "The UUID of the material catalog item this job material is based on. Links the job material to the corresponding material in the materials catalog.",
"format": "uuid",
"example": "123e4567-e645-4a4d-bb32-23f94a99a5ab",
"type": "string"
},
"name": {
"description": "The name of the material item used on the job. This is displayed on invoices and is used to identify the material to the customer. The name typically comes from the associated material object but can be customized per job.",
"type": "string",
"maxLength": 500
},
"quantity": {
"description": "The quantity of this material used on the job. This field is mandatory and cannot be empty.",
"type": "string",
"maxLength": 100
},
"price": {
"description": "The unit price of the material excluding tax. Used in calculations to determine the total price for this line item on the job. The system may automatically adjust this value to maintain consistency with tax-inclusive pricing.",
"type": "string"
},
"displayed_amount": {
"description": "The unit price amount as displayed on invoices and quotes. This can be either tax-inclusive or tax-exclusive depending on the displayed_amount_is_tax_inclusive field value. Used for presentation to customers.",
"type": "string"
},
"displayed_amount_is_tax_inclusive": {
"description": "Boolean flag indicating whether the displayed_amount includes tax (true) or excludes tax (false). This controls how prices are presented to customers and determines which price value (inclusive or exclusive) is used in calculations.",
"type": "string"
},
"tax_rate_uuid": {
"description": "The UUID of the tax rate applied to this job material. Determines how tax is calculated for this specific line item.",
"format": "uuid",
"example": "123e4567-1528-43e7-a99b-23f944dde9cb",
"type": "string"
},
"sort_order": {
"description": "Integer value controlling the display order of materials on a job. Lower values appear first in lists. Used to customize the presentation order of materials on quotes, invoices and job forms.",
"type": "string"
},
"cost": {
"description": "The cost of the material for this job. This is the ex-tax amount.",
"type": "string"
},
"displayed_cost": {
"description": "The cost of the material for this job, displayed as inc-tax or ex-tax depending on jobMaterial.displayed_amount_is_tax_inclusive.",
"type": "string"
},
"uuid": {
"format": "uuid",
"description": "Unique identifier for this record",
"example": "123e4567-59d5-48ed-8c3e-23f94642c1db",
"type": "string"
},
"job_material_bundle_uuid": {
"format": "uuid",
"example": "123e4567-beac-430e-9edc-23f94068e49b",
"type": "string",
"description": "UUID of a JobMaterialBundle which this JobMaterial belongs to. The default value is blank, which means that the JobMaterial is not part of a JobMaterialBundle."
}
},
"required": [
"quantity"
]
}
}
},
"x-speakeasy-retries": {
"strategy": "backoff",
"backoff": {
"initialInterval": 500,
"maxInterval": 60000,
"maxElapsedTime": 3600000,
"exponent": 1.5
},
"statusCodes": [
"5XX",
"429"
],
"retryConnectionErrors": true
},
"tags": [
{
"name": "Job Materials",
"description": "Operations related to Job Materials"
}
]
}
```
+380
View File
@@ -0,0 +1,380 @@
# Delete a Job Material
In ServiceM8, deleting a record sets its `active` field to `0`. Inactive records are still accessible on the API, but are hidden in the UI. Inactive records can be restored by setting their `active` field to `1`.
#### OAuth Scope
This endpoint requires the following OAuth scope **manage_job_materials**.
# OpenAPI definition
```json
{
"openapi": "3.1.0",
"info": {
"title": "ServiceM8 API",
"description": "Move your app forward with the ServiceM8 API\n\n\n\n## Limits and Throttling\nTo ensure continuous quality of service, API usage can be subject to throttling. The throttle will be applied once an API consumer reaches a certain \nthreshold in terms of a maximum number of requests per minute. Most clients will never hit this threshold, but those that do, will get met by a \nHTTP 429 Too Many Requests response code. \n \nThere is a limit of 180 requests per minute, if you reach this you will receive a HTTP 429 with a text body of \"Number of allowed API requests per minute exceeded\".\nThere is a limit of 20000 requests per day, if you reach this you will receive a HTTP 429 with a text body of \"Number of allowed API requests per day exceeded\".\n\nWe encourage all API developers to anticipate this error, and take appropriate measures like e.g. using a cached value from a previous call, or passing on a message to the end user that gets subjected to this behaviour (if any).\n\nLimits are per Addon per account.\n",
"termsOfService": "https://www.servicem8.com/terms-of-service",
"version": "1.0.0"
},
"servers": [
{
"url": "https://api.servicem8.com/api_1.0"
}
],
"security": [
{
"apiKey": []
},
{
"oauth2": []
}
],
"paths": {
"/jobmaterial/{uuid}.json": {
"delete": {
"tags": [
"Job Materials"
],
"operationId": "deleteJobMaterials",
"summary": "Delete a Job Material",
"description": "\n\t\t\t\nIn ServiceM8, deleting a record sets its `active` field to `0`. Inactive records are still accessible on the API, but are hidden in the UI. Inactive records can be restored by setting their `active` field to `1`.\n\n\t\t\t\n\t\t\t\n#### OAuth Scope\nThis endpoint requires the following OAuth scope **manage_job_materials**.\n\n\t\t\t",
"security": [
{
"apiKey": []
},
{
"oauth2": [
"manage_job_materials"
]
}
],
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the Job Material",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "Job Material successfully archived (soft deleted)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Result"
},
"examples": {
"success": {
"value": {
"errorCode": "0",
"message": "OK"
}
}
}
}
}
},
"400": {
"description": "Bad Request - The request is malformed or contains invalid parameters",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
},
"examples": {
"badRequest": {
"value": {
"errorCode": "1000",
"message": "An error occurred completing your request"
}
}
}
}
}
},
"401": {
"description": "Unauthorized - Authentication credentials are missing or invalid",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AuthenticationError"
},
"examples": {
"unauthorized": {
"value": {
"errorCode": "401",
"message": "Authentication failed. Please check your API key or OAuth token."
}
}
}
}
}
},
"403": {
"description": "Forbidden - You don't have permission to delete this resource",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ForbiddenError"
},
"examples": {
"forbidden": {
"value": {
"errorCode": "403",
"message": "Access forbidden. You don't have permission to access this resource."
}
}
}
}
}
},
"404": {
"description": "Not Found - The record to delete does not exist or has already been deleted",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotFoundError"
},
"examples": {
"notFound": {
"value": {
"errorCode": "404",
"message": "Resource not found. The requested record does not exist or has been deleted."
}
}
}
}
}
},
"429": {
"description": "Too Many Requests - You have exceeded the rate limit",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RateLimitError"
},
"examples": {
"rateLimitMinute": {
"value": {
"errorCode": 429,
"message": "Number of allowed API requests per minute exceeded"
}
},
"rateLimitDay": {
"value": {
"errorCode": 429,
"message": "Number of allowed API requests per day exceeded"
}
}
}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred on the server",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
},
"examples": {
"serverError": {
"value": {
"errorCode": 500,
"message": "An unexpected error occurred. Please try again later."
}
}
}
}
}
}
}
}
}
},
"components": {
"securitySchemes": {
"apiKey": {
"type": "apiKey",
"name": "X-Api-Key",
"in": "header"
},
"oauth2": {
"type": "oauth2",
"flows": {
"authorizationCode": {
"authorizationUrl": "https://api.servicem8.com/oauth/authorize",
"tokenUrl": "https://api.servicem8.com/oauth/access_token",
"scopes": {
"staff_locations": "Access to real-time GPS information about staff",
"staff_activity": "Access to clock on, lunch break and clock off information about staff",
"publish_sms": "Access to send SMS messages to customers and/or staff on your behalf. Note sending SMS messages will incur account charges.",
"publish_email": "Access to send Email messages to customers and/or staff on your behalf",
"vendor": "Access to basic account information",
"vendor_logo": "Access to account logo",
"vendor_email": "Access to account holder email address",
"read_locations": "Read-only access to Location Endpoint",
"manage_locations": "Full access to Location Endpoint",
"read_staff": "Read-only access to Staff Endpoint",
"manage_staff": "Full access to Staff Endpoint",
"read_customers": "Read-only access to Company Endpoint",
"manage_customers": "Full access to Company Endpoint",
"read_customer_contacts": "Read-only access to CompanyContact Endpoint",
"manage_customer_contacts": "Full access to CompanyContact Endpoint",
"read_jobs": "Read-only access to Job Endpoint",
"manage_jobs": "Full access to Job Endpoint",
"create_jobs": "Ability to create jobs on behalf of account. Note creating jobs may incur account charges.",
"read_job_contacts": "Read-only access to JobContact Endpoint",
"manage_job_contacts": "Full access to JobContact Endpoint",
"read_job_materials": "Read-only access to JobMaterials Endpoint",
"manage_job_materials": "Full access to JobMaterials Endpoint",
"read_job_categories": "Read-only access to Categories Endpoint",
"manage_job_categories": "Full access to Categories Endpoint",
"read_job_queues": "Read-only access to Job Queues Endpoint",
"manage_job_queues": "Full access to Job Queues Endpoint",
"read_tasks": "Read-only access to Tasks Endpoint",
"manage_tasks": "Full access to Tasks Endpoint",
"read_schedule": "Read-only access to JobActivity Endpoint",
"manage_schedule": "Full access to JobActivity Endpoint",
"read_inventory": "Read-only access to Materials Endpoint",
"manage_inventory": "Full access to Materials Endpoint",
"read_job_notes": "Read-only access to job notes",
"publish_job_notes": "Ability to add new job notes",
"read_job_photos": "Read-only access to job photos",
"publish_job_photos": "Ability to add new job photos",
"read_attachments": "Read-only access to Attachments Endpoint",
"manage_attachments": "Full access to Attachments Endpoint",
"read_inbox": "Read-only access to inbox messages",
"read_messages": "Read-only access to staff messages",
"manage_notifications": "Ability to read notifications and mark as read",
"manage_templates": "Full-access to email, sms and document templates",
"manage_badges": "Full-access to create/modify job badges",
"read_assets": "Read-only access to Assets Endpoint",
"manage_assets": "Full access to Assets Endpoint",
"read_knowledge_base": "Read-only access to Knowledge Base Endpoint",
"manage_knowledge_base": "Full access to Knowledge Base Endpoint"
}
}
}
}
},
"schemas": {
"Result": {
"type": "object",
"properties": {
"errorCode": {
"type": "number",
"format": "int32",
"example": "0"
},
"message": {
"type": "string",
"example": "OK"
}
}
},
"Error": {
"type": "object",
"properties": {
"errorCode": {
"type": "number",
"format": "int32",
"example": "1000"
},
"message": {
"type": "string",
"example": "An error occurred completing your request"
}
}
},
"RateLimitError": {
"type": "object",
"properties": {
"errorCode": {
"type": "number",
"format": "int32",
"example": "429"
},
"message": {
"type": "string",
"example": "Number of allowed API requests per minute exceeded"
}
}
},
"AuthenticationError": {
"type": "object",
"properties": {
"errorCode": {
"type": "number",
"format": "int32",
"example": "401"
},
"message": {
"type": "string",
"example": "Authentication failed. Please check your API key or OAuth token."
}
}
},
"ForbiddenError": {
"type": "object",
"properties": {
"errorCode": {
"type": "number",
"format": "int32",
"example": "403"
},
"message": {
"type": "string",
"example": "Access forbidden. You don't have permission to access this resource."
}
}
},
"NotFoundError": {
"type": "object",
"properties": {
"errorCode": {
"type": "number",
"format": "int32",
"example": "404"
},
"message": {
"type": "string",
"example": "Resource not found. The requested record does not exist or has been deleted."
}
}
}
}
},
"x-speakeasy-retries": {
"strategy": "backoff",
"backoff": {
"initialInterval": 500,
"maxInterval": 60000,
"maxElapsedTime": 3600000,
"exponent": 1.5
},
"statusCodes": [
"5XX",
"429"
],
"retryConnectionErrors": true
},
"tags": [
{
"name": "Job Materials",
"description": "Operations related to Job Materials"
}
]
}
```
+426
View File
@@ -0,0 +1,426 @@
# List all Job Materials
#### Filtering
This endpoint supports result filtering. For more information on how to filter this request, [go here](/docs/filtering).
#### OAuth Scope
This endpoint requires the following OAuth scope **read_job_materials**.
# OpenAPI definition
```json
{
"openapi": "3.1.0",
"info": {
"title": "ServiceM8 API",
"description": "Move your app forward with the ServiceM8 API\n\n\n\n## Limits and Throttling\nTo ensure continuous quality of service, API usage can be subject to throttling. The throttle will be applied once an API consumer reaches a certain \nthreshold in terms of a maximum number of requests per minute. Most clients will never hit this threshold, but those that do, will get met by a \nHTTP 429 Too Many Requests response code. \n \nThere is a limit of 180 requests per minute, if you reach this you will receive a HTTP 429 with a text body of \"Number of allowed API requests per minute exceeded\".\nThere is a limit of 20000 requests per day, if you reach this you will receive a HTTP 429 with a text body of \"Number of allowed API requests per day exceeded\".\n\nWe encourage all API developers to anticipate this error, and take appropriate measures like e.g. using a cached value from a previous call, or passing on a message to the end user that gets subjected to this behaviour (if any).\n\nLimits are per Addon per account.\n",
"termsOfService": "https://www.servicem8.com/terms-of-service",
"version": "1.0.0"
},
"servers": [
{
"url": "https://api.servicem8.com/api_1.0"
}
],
"security": [
{
"apiKey": []
},
{
"oauth2": []
}
],
"paths": {
"/jobmaterial.json": {
"get": {
"tags": [
"Job Materials"
],
"operationId": "listJobMaterials",
"summary": "List all Job Materials",
"description": "\n\t\t\t\n#### Filtering\nThis endpoint supports result filtering. For more information on how to filter this request, [go here](/docs/filtering).\n\t\t\t\n\t\t\t\n#### OAuth Scope\nThis endpoint requires the following OAuth scope **read_job_materials**.\n\n\t\t\t",
"security": [
{
"apiKey": []
},
{
"oauth2": [
"read_job_materials"
]
}
],
"responses": {
"200": {
"description": "An array of Job Materials",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/JobMaterial"
}
},
"examples": {
"success": {
"value": [
{
"uuid": "123e4567-3d48-4b63-b3de-23f941b2cd9b",
"active": 1,
"edit_date": "2026-03-01 12:00:00",
"job_uuid": "123e4567-753c-489e-ac37-23f944ea19db",
"material_uuid": "123e4567-6560-4d46-ba4d-23f946712ceb",
"name": "string",
"quantity": "string",
"price": "string",
"displayed_amount": "string",
"displayed_amount_is_tax_inclusive": "string",
"tax_rate_uuid": "123e4567-9191-4380-b40b-23f94405515b",
"sort_order": "string",
"cost": "string",
"displayed_cost": "string",
"job_material_bundle_uuid": "123e4567-8392-4c56-89c4-23f94210084b"
}
]
}
}
}
}
},
"400": {
"description": "Bad Request - The request is malformed or contains invalid parameters",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
},
"examples": {
"badRequest": {
"value": {
"errorCode": "1000",
"message": "An error occurred completing your request"
}
}
}
}
}
},
"401": {
"description": "Unauthorized - Authentication credentials are missing or invalid",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AuthenticationError"
},
"examples": {
"unauthorized": {
"value": {
"errorCode": "401",
"message": "Authentication failed. Please check your API key or OAuth token."
}
}
}
}
}
},
"403": {
"description": "Forbidden - You don't have permission to access this resource",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ForbiddenError"
},
"examples": {
"forbidden": {
"value": {
"errorCode": "403",
"message": "Access forbidden. You don't have permission to access this resource."
}
}
}
}
}
},
"429": {
"description": "Too Many Requests - You have exceeded the rate limit",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RateLimitError"
},
"examples": {
"rateLimitMinute": {
"value": {
"errorCode": 429,
"message": "Number of allowed API requests per minute exceeded"
}
},
"rateLimitDay": {
"value": {
"errorCode": 429,
"message": "Number of allowed API requests per day exceeded"
}
}
}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred on the server",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
},
"examples": {
"serverError": {
"value": {
"errorCode": 500,
"message": "An unexpected error occurred. Please try again later."
}
}
}
}
}
}
}
}
}
},
"components": {
"securitySchemes": {
"apiKey": {
"type": "apiKey",
"name": "X-Api-Key",
"in": "header"
},
"oauth2": {
"type": "oauth2",
"flows": {
"authorizationCode": {
"authorizationUrl": "https://api.servicem8.com/oauth/authorize",
"tokenUrl": "https://api.servicem8.com/oauth/access_token",
"scopes": {
"staff_locations": "Access to real-time GPS information about staff",
"staff_activity": "Access to clock on, lunch break and clock off information about staff",
"publish_sms": "Access to send SMS messages to customers and/or staff on your behalf. Note sending SMS messages will incur account charges.",
"publish_email": "Access to send Email messages to customers and/or staff on your behalf",
"vendor": "Access to basic account information",
"vendor_logo": "Access to account logo",
"vendor_email": "Access to account holder email address",
"read_locations": "Read-only access to Location Endpoint",
"manage_locations": "Full access to Location Endpoint",
"read_staff": "Read-only access to Staff Endpoint",
"manage_staff": "Full access to Staff Endpoint",
"read_customers": "Read-only access to Company Endpoint",
"manage_customers": "Full access to Company Endpoint",
"read_customer_contacts": "Read-only access to CompanyContact Endpoint",
"manage_customer_contacts": "Full access to CompanyContact Endpoint",
"read_jobs": "Read-only access to Job Endpoint",
"manage_jobs": "Full access to Job Endpoint",
"create_jobs": "Ability to create jobs on behalf of account. Note creating jobs may incur account charges.",
"read_job_contacts": "Read-only access to JobContact Endpoint",
"manage_job_contacts": "Full access to JobContact Endpoint",
"read_job_materials": "Read-only access to JobMaterials Endpoint",
"manage_job_materials": "Full access to JobMaterials Endpoint",
"read_job_categories": "Read-only access to Categories Endpoint",
"manage_job_categories": "Full access to Categories Endpoint",
"read_job_queues": "Read-only access to Job Queues Endpoint",
"manage_job_queues": "Full access to Job Queues Endpoint",
"read_tasks": "Read-only access to Tasks Endpoint",
"manage_tasks": "Full access to Tasks Endpoint",
"read_schedule": "Read-only access to JobActivity Endpoint",
"manage_schedule": "Full access to JobActivity Endpoint",
"read_inventory": "Read-only access to Materials Endpoint",
"manage_inventory": "Full access to Materials Endpoint",
"read_job_notes": "Read-only access to job notes",
"publish_job_notes": "Ability to add new job notes",
"read_job_photos": "Read-only access to job photos",
"publish_job_photos": "Ability to add new job photos",
"read_attachments": "Read-only access to Attachments Endpoint",
"manage_attachments": "Full access to Attachments Endpoint",
"read_inbox": "Read-only access to inbox messages",
"read_messages": "Read-only access to staff messages",
"manage_notifications": "Ability to read notifications and mark as read",
"manage_templates": "Full-access to email, sms and document templates",
"manage_badges": "Full-access to create/modify job badges",
"read_assets": "Read-only access to Assets Endpoint",
"manage_assets": "Full access to Assets Endpoint",
"read_knowledge_base": "Read-only access to Knowledge Base Endpoint",
"manage_knowledge_base": "Full access to Knowledge Base Endpoint"
}
}
}
}
},
"schemas": {
"Error": {
"type": "object",
"properties": {
"errorCode": {
"type": "number",
"format": "int32",
"example": "1000"
},
"message": {
"type": "string",
"example": "An error occurred completing your request"
}
}
},
"RateLimitError": {
"type": "object",
"properties": {
"errorCode": {
"type": "number",
"format": "int32",
"example": "429"
},
"message": {
"type": "string",
"example": "Number of allowed API requests per minute exceeded"
}
}
},
"AuthenticationError": {
"type": "object",
"properties": {
"errorCode": {
"type": "number",
"format": "int32",
"example": "401"
},
"message": {
"type": "string",
"example": "Authentication failed. Please check your API key or OAuth token."
}
}
},
"ForbiddenError": {
"type": "object",
"properties": {
"errorCode": {
"type": "number",
"format": "int32",
"example": "403"
},
"message": {
"type": "string",
"example": "Access forbidden. You don't have permission to access this resource."
}
}
},
"JobMaterial": {
"type": "object",
"properties": {
"job_uuid": {
"description": "The UUID of the job this material is associated with. This is a required field that establishes the relationship between the job material and its parent job.",
"format": "uuid",
"example": "123e4567-9539-4f05-ba2e-23f943147fab",
"type": "string"
},
"material_uuid": {
"description": "The UUID of the material catalog item this job material is based on. Links the job material to the corresponding material in the materials catalog.",
"format": "uuid",
"example": "123e4567-6664-44df-8cc2-23f94cbf8a9b",
"type": "string"
},
"name": {
"description": "The name of the material item used on the job. This is displayed on invoices and is used to identify the material to the customer. The name typically comes from the associated material object but can be customized per job.",
"type": "string",
"maxLength": 500
},
"quantity": {
"description": "The quantity of this material used on the job. This field is mandatory and cannot be empty.",
"type": "string",
"maxLength": 100
},
"price": {
"description": "The unit price of the material excluding tax. Used in calculations to determine the total price for this line item on the job. The system may automatically adjust this value to maintain consistency with tax-inclusive pricing.",
"type": "string"
},
"displayed_amount": {
"description": "The unit price amount as displayed on invoices and quotes. This can be either tax-inclusive or tax-exclusive depending on the displayed_amount_is_tax_inclusive field value. Used for presentation to customers.",
"type": "string"
},
"displayed_amount_is_tax_inclusive": {
"description": "Boolean flag indicating whether the displayed_amount includes tax (true) or excludes tax (false). This controls how prices are presented to customers and determines which price value (inclusive or exclusive) is used in calculations.",
"type": "string"
},
"tax_rate_uuid": {
"description": "The UUID of the tax rate applied to this job material. Determines how tax is calculated for this specific line item.",
"format": "uuid",
"example": "123e4567-a746-4be2-bd00-23f949815b5b",
"type": "string"
},
"sort_order": {
"description": "Integer value controlling the display order of materials on a job. Lower values appear first in lists. Used to customize the presentation order of materials on quotes, invoices and job forms.",
"type": "string"
},
"cost": {
"description": "The cost of the material for this job. This is the ex-tax amount.",
"type": "string"
},
"displayed_cost": {
"description": "The cost of the material for this job, displayed as inc-tax or ex-tax depending on jobMaterial.displayed_amount_is_tax_inclusive.",
"type": "string"
},
"uuid": {
"format": "uuid",
"description": "Unique identifier for this record",
"example": "123e4567-be6b-4a92-a2d6-23f94483836b",
"type": "string"
},
"active": {
"enum": [
0,
1
],
"type": "integer",
"default": 1,
"description": "Record active/deleted flag. Valid values are [0,1]"
},
"edit_date": {
"example": "2026-03-01 12:00:00",
"readOnly": true,
"description": "Timestamp at which record was last modified"
},
"job_material_bundle_uuid": {
"format": "uuid",
"example": "123e4567-50da-46fd-9a5e-23f941ffeacb",
"type": "string",
"description": "UUID of a JobMaterialBundle which this JobMaterial belongs to. The default value is blank, which means that the JobMaterial is not part of a JobMaterialBundle."
}
},
"required": [
"quantity"
]
}
}
},
"x-speakeasy-retries": {
"strategy": "backoff",
"backoff": {
"initialInterval": 500,
"maxInterval": 60000,
"maxElapsedTime": 3600000,
"exponent": 1.5
},
"statusCodes": [
"5XX",
"429"
],
"retryConnectionErrors": true
},
"tags": [
{
"name": "Job Materials",
"description": "Operations related to Job Materials"
}
]
}
```
+459
View File
@@ -0,0 +1,459 @@
# Update a Job Material
#### OAuth Scope
This endpoint requires the following OAuth scope **manage_job_materials**.
# OpenAPI definition
```json
{
"openapi": "3.1.0",
"info": {
"title": "ServiceM8 API",
"description": "Move your app forward with the ServiceM8 API\n\n\n\n## Limits and Throttling\nTo ensure continuous quality of service, API usage can be subject to throttling. The throttle will be applied once an API consumer reaches a certain \nthreshold in terms of a maximum number of requests per minute. Most clients will never hit this threshold, but those that do, will get met by a \nHTTP 429 Too Many Requests response code. \n \nThere is a limit of 180 requests per minute, if you reach this you will receive a HTTP 429 with a text body of \"Number of allowed API requests per minute exceeded\".\nThere is a limit of 20000 requests per day, if you reach this you will receive a HTTP 429 with a text body of \"Number of allowed API requests per day exceeded\".\n\nWe encourage all API developers to anticipate this error, and take appropriate measures like e.g. using a cached value from a previous call, or passing on a message to the end user that gets subjected to this behaviour (if any).\n\nLimits are per Addon per account.\n",
"termsOfService": "https://www.servicem8.com/terms-of-service",
"version": "1.0.0"
},
"servers": [
{
"url": "https://api.servicem8.com/api_1.0"
}
],
"security": [
{
"apiKey": []
},
{
"oauth2": []
}
],
"paths": {
"/jobmaterial/{uuid}.json": {
"post": {
"tags": [
"Job Materials"
],
"operationId": "updateJobMaterials",
"summary": "Update a Job Material",
"description": "\n\t\t\t\n#### OAuth Scope\nThis endpoint requires the following OAuth scope **manage_job_materials**.\n\n\t\t\t",
"security": [
{
"apiKey": []
},
{
"oauth2": [
"manage_job_materials"
]
}
],
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the Job Material",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"description": "Job Material fields to update",
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/JobMaterialCreate"
}
}
}
},
"responses": {
"200": {
"description": "Success - The record was updated successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Result"
},
"examples": {
"success": {
"value": {
"errorCode": "0",
"message": "OK"
}
}
}
}
}
},
"400": {
"description": "Bad Request - The request is malformed or contains invalid parameters",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
},
"examples": {
"badRequest": {
"value": {
"errorCode": "1000",
"message": "An error occurred completing your request"
}
}
}
}
}
},
"401": {
"description": "Unauthorized - Authentication credentials are missing or invalid",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AuthenticationError"
},
"examples": {
"unauthorized": {
"value": {
"errorCode": "401",
"message": "Authentication failed. Please check your API key or OAuth token."
}
}
}
}
}
},
"403": {
"description": "Forbidden - You don't have permission to update this resource",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ForbiddenError"
},
"examples": {
"forbidden": {
"value": {
"errorCode": "403",
"message": "Access forbidden. You don't have permission to access this resource."
}
}
}
}
}
},
"404": {
"description": "Not Found - The record to update does not exist or has been deleted",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/NotFoundError"
},
"examples": {
"notFound": {
"value": {
"errorCode": "404",
"message": "Resource not found. The requested record does not exist or has been deleted."
}
}
}
}
}
},
"429": {
"description": "Too Many Requests - You have exceeded the rate limit",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RateLimitError"
},
"examples": {
"rateLimitMinute": {
"value": {
"errorCode": 429,
"message": "Number of allowed API requests per minute exceeded"
}
},
"rateLimitDay": {
"value": {
"errorCode": 429,
"message": "Number of allowed API requests per day exceeded"
}
}
}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred on the server",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
},
"examples": {
"serverError": {
"value": {
"errorCode": 500,
"message": "An unexpected error occurred. Please try again later."
}
}
}
}
}
}
}
}
}
},
"components": {
"securitySchemes": {
"apiKey": {
"type": "apiKey",
"name": "X-Api-Key",
"in": "header"
},
"oauth2": {
"type": "oauth2",
"flows": {
"authorizationCode": {
"authorizationUrl": "https://api.servicem8.com/oauth/authorize",
"tokenUrl": "https://api.servicem8.com/oauth/access_token",
"scopes": {
"staff_locations": "Access to real-time GPS information about staff",
"staff_activity": "Access to clock on, lunch break and clock off information about staff",
"publish_sms": "Access to send SMS messages to customers and/or staff on your behalf. Note sending SMS messages will incur account charges.",
"publish_email": "Access to send Email messages to customers and/or staff on your behalf",
"vendor": "Access to basic account information",
"vendor_logo": "Access to account logo",
"vendor_email": "Access to account holder email address",
"read_locations": "Read-only access to Location Endpoint",
"manage_locations": "Full access to Location Endpoint",
"read_staff": "Read-only access to Staff Endpoint",
"manage_staff": "Full access to Staff Endpoint",
"read_customers": "Read-only access to Company Endpoint",
"manage_customers": "Full access to Company Endpoint",
"read_customer_contacts": "Read-only access to CompanyContact Endpoint",
"manage_customer_contacts": "Full access to CompanyContact Endpoint",
"read_jobs": "Read-only access to Job Endpoint",
"manage_jobs": "Full access to Job Endpoint",
"create_jobs": "Ability to create jobs on behalf of account. Note creating jobs may incur account charges.",
"read_job_contacts": "Read-only access to JobContact Endpoint",
"manage_job_contacts": "Full access to JobContact Endpoint",
"read_job_materials": "Read-only access to JobMaterials Endpoint",
"manage_job_materials": "Full access to JobMaterials Endpoint",
"read_job_categories": "Read-only access to Categories Endpoint",
"manage_job_categories": "Full access to Categories Endpoint",
"read_job_queues": "Read-only access to Job Queues Endpoint",
"manage_job_queues": "Full access to Job Queues Endpoint",
"read_tasks": "Read-only access to Tasks Endpoint",
"manage_tasks": "Full access to Tasks Endpoint",
"read_schedule": "Read-only access to JobActivity Endpoint",
"manage_schedule": "Full access to JobActivity Endpoint",
"read_inventory": "Read-only access to Materials Endpoint",
"manage_inventory": "Full access to Materials Endpoint",
"read_job_notes": "Read-only access to job notes",
"publish_job_notes": "Ability to add new job notes",
"read_job_photos": "Read-only access to job photos",
"publish_job_photos": "Ability to add new job photos",
"read_attachments": "Read-only access to Attachments Endpoint",
"manage_attachments": "Full access to Attachments Endpoint",
"read_inbox": "Read-only access to inbox messages",
"read_messages": "Read-only access to staff messages",
"manage_notifications": "Ability to read notifications and mark as read",
"manage_templates": "Full-access to email, sms and document templates",
"manage_badges": "Full-access to create/modify job badges",
"read_assets": "Read-only access to Assets Endpoint",
"manage_assets": "Full access to Assets Endpoint",
"read_knowledge_base": "Read-only access to Knowledge Base Endpoint",
"manage_knowledge_base": "Full access to Knowledge Base Endpoint"
}
}
}
}
},
"schemas": {
"Result": {
"type": "object",
"properties": {
"errorCode": {
"type": "number",
"format": "int32",
"example": "0"
},
"message": {
"type": "string",
"example": "OK"
}
}
},
"Error": {
"type": "object",
"properties": {
"errorCode": {
"type": "number",
"format": "int32",
"example": "1000"
},
"message": {
"type": "string",
"example": "An error occurred completing your request"
}
}
},
"RateLimitError": {
"type": "object",
"properties": {
"errorCode": {
"type": "number",
"format": "int32",
"example": "429"
},
"message": {
"type": "string",
"example": "Number of allowed API requests per minute exceeded"
}
}
},
"AuthenticationError": {
"type": "object",
"properties": {
"errorCode": {
"type": "number",
"format": "int32",
"example": "401"
},
"message": {
"type": "string",
"example": "Authentication failed. Please check your API key or OAuth token."
}
}
},
"ForbiddenError": {
"type": "object",
"properties": {
"errorCode": {
"type": "number",
"format": "int32",
"example": "403"
},
"message": {
"type": "string",
"example": "Access forbidden. You don't have permission to access this resource."
}
}
},
"NotFoundError": {
"type": "object",
"properties": {
"errorCode": {
"type": "number",
"format": "int32",
"example": "404"
},
"message": {
"type": "string",
"example": "Resource not found. The requested record does not exist or has been deleted."
}
}
},
"JobMaterialCreate": {
"type": "object",
"properties": {
"job_uuid": {
"description": "The UUID of the job this material is associated with. This is a required field that establishes the relationship between the job material and its parent job.",
"format": "uuid",
"example": "123e4567-6110-4f36-93cc-23f94736b95b",
"type": "string"
},
"material_uuid": {
"description": "The UUID of the material catalog item this job material is based on. Links the job material to the corresponding material in the materials catalog.",
"format": "uuid",
"example": "123e4567-e645-4a4d-bb32-23f94a99a5ab",
"type": "string"
},
"name": {
"description": "The name of the material item used on the job. This is displayed on invoices and is used to identify the material to the customer. The name typically comes from the associated material object but can be customized per job.",
"type": "string",
"maxLength": 500
},
"quantity": {
"description": "The quantity of this material used on the job. This field is mandatory and cannot be empty.",
"type": "string",
"maxLength": 100
},
"price": {
"description": "The unit price of the material excluding tax. Used in calculations to determine the total price for this line item on the job. The system may automatically adjust this value to maintain consistency with tax-inclusive pricing.",
"type": "string"
},
"displayed_amount": {
"description": "The unit price amount as displayed on invoices and quotes. This can be either tax-inclusive or tax-exclusive depending on the displayed_amount_is_tax_inclusive field value. Used for presentation to customers.",
"type": "string"
},
"displayed_amount_is_tax_inclusive": {
"description": "Boolean flag indicating whether the displayed_amount includes tax (true) or excludes tax (false). This controls how prices are presented to customers and determines which price value (inclusive or exclusive) is used in calculations.",
"type": "string"
},
"tax_rate_uuid": {
"description": "The UUID of the tax rate applied to this job material. Determines how tax is calculated for this specific line item.",
"format": "uuid",
"example": "123e4567-1528-43e7-a99b-23f944dde9cb",
"type": "string"
},
"sort_order": {
"description": "Integer value controlling the display order of materials on a job. Lower values appear first in lists. Used to customize the presentation order of materials on quotes, invoices and job forms.",
"type": "string"
},
"cost": {
"description": "The cost of the material for this job. This is the ex-tax amount.",
"type": "string"
},
"displayed_cost": {
"description": "The cost of the material for this job, displayed as inc-tax or ex-tax depending on jobMaterial.displayed_amount_is_tax_inclusive.",
"type": "string"
},
"uuid": {
"format": "uuid",
"description": "Unique identifier for this record",
"example": "123e4567-59d5-48ed-8c3e-23f94642c1db",
"type": "string"
},
"job_material_bundle_uuid": {
"format": "uuid",
"example": "123e4567-beac-430e-9edc-23f94068e49b",
"type": "string",
"description": "UUID of a JobMaterialBundle which this JobMaterial belongs to. The default value is blank, which means that the JobMaterial is not part of a JobMaterialBundle."
}
},
"required": [
"quantity"
]
}
}
},
"x-speakeasy-retries": {
"strategy": "backoff",
"backoff": {
"initialInterval": 500,
"maxInterval": 60000,
"maxElapsedTime": 3600000,
"exponent": 1.5
},
"statusCodes": [
"5XX",
"429"
],
"retryConnectionErrors": true
},
"tags": [
{
"name": "Job Materials",
"description": "Operations related to Job Materials"
}
]
}
```
+196
View File
@@ -0,0 +1,196 @@
import argparse
import json
from pathlib import Path
from typing import Any, Dict, List, Optional
QUOTE_TEMPLATE_FORM_UUID = "3621b6be-1d19-4756-9ab4-9d5e4120f6d9"
QUOTE_INCLUDE_HEADER_FIELD_UUID = "b752046f-2543-40a8-82bf-241f035a6c3d"
QUOTE_INCLUDE_HEADER_MATERIAL_UUID = "1924893b-917f-474a-adaa-2093bd622d4b"
QUOTE_INCLUDE_ITEM_MATERIAL_UUID = "8c00ca29-2178-403e-be76-241cfaddeedb"
QUOTE_EXCLUDE_HEADER_FIELD_UUID = "5e9aeda9-2c59-43db-ba64-241f0b7812bd"
QUOTE_EXCLUDE_HEADER_MATERIAL_UUID = "4947bfd7-4875-48f7-9caf-2093b9751b9b"
QUOTE_EXCLUDE_ITEM_MATERIAL_UUID = "8c00ca29-2178-403e-be76-241cfaddeedb"
def clean_text(value: Any) -> str:
if value is None:
return ""
return str(value).replace("\r\n", "\n").replace("\r", "\n").strip()
def parse_field_data(field_data: Any) -> List[Dict[str, Any]]:
if isinstance(field_data, str):
return json.loads(field_data)
if isinstance(field_data, list):
return field_data
raise ValueError("field_data must be a JSON string or list")
def sort_key(row: Dict[str, Any]):
return int(row.get("SortOrder", 9999) or 9999)
def build_job_material_line(
kind: str,
name: str,
material_uuid: str,
sort_order: int,
source_question: Optional[str] = None,
source_field_uuid: Optional[str] = None,
) -> Dict[str, Any]:
return {
"kind": kind,
"name": clean_text(name),
"material_uuid": material_uuid,
"quantity": "1",
"price": "0",
"displayed_amount": "0",
"displayed_amount_is_tax_inclusive": "0",
"sort_order": str(sort_order),
"source_question": source_question or "",
"source_field_uuid": source_field_uuid or "",
}
def parse_quote_template_field_rows(field_rows: List[Dict[str, Any]]) -> Dict[str, Any]:
ordered = sorted(field_rows, key=sort_key)
description = ""
include_items: List[Dict[str, Any]] = []
exclude_items: List[Dict[str, Any]] = []
for row in ordered:
question = clean_text(row.get("Question"))
response = clean_text(row.get("Response"))
field_uuid = clean_text(row.get("UUID"))
if not question:
continue
if question == "Description of Works to be Quoted":
description = response
continue
if question.startswith("Item ") and response:
include_items.append(
{
"question": question,
"response": response,
"field_uuid": field_uuid,
"sort_order": int(row.get("SortOrder", 0) or 0),
}
)
continue
if question.startswith("Works excluded ") and response:
exclude_items.append(
{
"question": question,
"response": response,
"field_uuid": field_uuid,
"sort_order": int(row.get("SortOrder", 0) or 0),
}
)
continue
desired_job_materials: List[Dict[str, Any]] = []
next_sort = 100
if include_items:
desired_job_materials.append(
build_job_material_line(
kind="include_header",
name="QUOTE INCLUDES",
material_uuid=QUOTE_INCLUDE_HEADER_MATERIAL_UUID,
sort_order=next_sort,
source_question="QUOTE INCLUDES",
source_field_uuid=QUOTE_INCLUDE_HEADER_FIELD_UUID,
)
)
next_sort += 10
for item in include_items:
desired_job_materials.append(
build_job_material_line(
kind="include_item",
name=item["response"],
material_uuid=QUOTE_INCLUDE_ITEM_MATERIAL_UUID,
sort_order=next_sort,
source_question=item["question"],
source_field_uuid=item["field_uuid"],
)
)
next_sort += 10
if exclude_items:
if desired_job_materials:
next_sort = max(next_sort, 200)
desired_job_materials.append(
build_job_material_line(
kind="exclude_header",
name="QUOTE EXCLUDES",
material_uuid=QUOTE_EXCLUDE_HEADER_MATERIAL_UUID,
sort_order=next_sort,
source_question="QUOTE EXCLUDES",
source_field_uuid=QUOTE_EXCLUDE_HEADER_FIELD_UUID,
)
)
next_sort += 10
for item in exclude_items:
desired_job_materials.append(
build_job_material_line(
kind="exclude_item",
name=item["response"],
material_uuid=QUOTE_EXCLUDE_ITEM_MATERIAL_UUID,
sort_order=next_sort,
source_question=item["question"],
source_field_uuid=item["field_uuid"],
)
)
next_sort += 10
return {
"description": description,
"include_items": include_items,
"exclude_items": exclude_items,
"desired_job_materials": desired_job_materials,
}
def parse_quote_template_form_response(payload: Dict[str, Any]) -> Dict[str, Any]:
data = payload.get("data", {}) if isinstance(payload, dict) else {}
form_uuid = clean_text(data.get("form_uuid"))
if form_uuid and form_uuid != QUOTE_TEMPLATE_FORM_UUID:
raise ValueError(f"Not a Quote Template form response: {form_uuid}")
field_rows = parse_field_data(data.get("field_data", []))
parsed = parse_quote_template_field_rows(field_rows)
parsed["form_uuid"] = form_uuid
parsed["form_response_uuid"] = clean_text(data.get("uuid"))
parsed["job_uuid"] = clean_text(data.get("regarding_object_uuid"))
return parsed
def load_input_file(path: str) -> Dict[str, Any]:
raw = Path(path).read_text()
payload = json.loads(raw)
if isinstance(payload, dict) and "field_data" in payload and "data" not in payload:
return {"data": payload}
return payload
def main():
parser = argparse.ArgumentParser(description="Parse ServiceM8 Quote Template form responses into desired Job Material rows")
parser.add_argument("input", help="Path to JSON file containing full form response payload or a data object with field_data")
parser.add_argument("--pretty", action="store_true", help="Pretty-print output JSON")
args = parser.parse_args()
payload = load_input_file(args.input)
parsed = parse_quote_template_form_response(payload)
if args.pretty:
print(json.dumps(parsed, indent=2, ensure_ascii=False))
else:
print(json.dumps(parsed, ensure_ascii=False))
if __name__ == "__main__":
main()
+421
View File
@@ -0,0 +1,421 @@
import json
import os
import sqlite3
from contextlib import closing
from datetime import datetime
from html import escape
from urllib.parse import urlencode
from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import HTMLResponse
DB_PATH = os.getenv("WEBHOOK_DB_PATH", "./servicem8_webhooks.db")
APP_HOST = os.getenv("WEBHOOK_INSPECTOR_HOST", "127.0.0.1")
APP_PORT = int(os.getenv("WEBHOOK_INSPECTOR_PORT", "18355"))
PAGE_SIZE = 50
app = FastAPI(title="ServiceM8 Inspector", version="0.1.0")
def get_conn():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def html_page(title: str, body: str) -> HTMLResponse:
nav = """
<nav>
<a href='/'>Dashboard</a>
<a href='/events'>Events</a>
<a href='/objects'>Objects</a>
<a href='/form-responses'>Form responses</a>
</nav>
"""
css = """
<style>
body { font-family: Inter, system-ui, sans-serif; margin: 24px; color: #1f2937; background: #f8fafc; }
nav { margin-bottom: 20px; display: flex; gap: 14px; flex-wrap: wrap; }
nav a { text-decoration: none; color: #0f766e; font-weight: 600; }
h1, h2, h3 { margin-bottom: 0.4rem; }
.muted { color: #6b7280; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin: 16px 0 24px; }
.card { background: white; border: 1px solid #e5e7eb; border-radius: 10px; padding: 14px; box-shadow: 0 1px 2px rgba(0,0,0,0.05); }
.big { font-size: 1.6rem; font-weight: 700; }
table { width: 100%; border-collapse: collapse; background: white; }
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #e5e7eb; vertical-align: top; }
th { background: #f1f5f9; position: sticky; top: 0; }
tr:hover td { background: #f8fafc; }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
pre { white-space: pre-wrap; word-break: break-word; background: #0f172a; color: #e2e8f0; padding: 14px; border-radius: 10px; overflow-x: auto; }
.pill { display: inline-block; padding: 2px 8px; border-radius: 999px; background: #e0f2fe; color: #075985; font-size: 0.85rem; margin: 2px 4px 2px 0; }
.section { margin: 24px 0; }
.toolbar { background: white; border: 1px solid #e5e7eb; border-radius: 10px; padding: 12px; margin-bottom: 16px; }
input[type='text'] { padding: 8px; width: 280px; max-width: 100%; }
button { padding: 8px 12px; }
.pagination { margin-top: 16px; display: flex; gap: 12px; }
.summary-grid { display: grid; grid-template-columns: 220px 1fr; gap: 8px 16px; }
.summary-grid div { padding: 4px 0; }
details { margin: 12px 0; }
a { color: #0f766e; }
</style>
"""
return HTMLResponse(f"<!doctype html><html><head><meta charset='utf-8'><title>{escape(title)}</title>{css}</head><body>{nav}<h1>{escape(title)}</h1>{body}</body></html>")
def pretty_json(value):
try:
if isinstance(value, str):
parsed = json.loads(value)
else:
parsed = value
return json.dumps(parsed, indent=2, ensure_ascii=False)
except Exception:
return str(value)
def parse_json_field(value, default=None):
if value is None:
return default
try:
return json.loads(value)
except Exception:
return default
def event_summary(payload):
data = payload.get("data", {}) if isinstance(payload, dict) else {}
related = payload.get("related", {}) if isinstance(payload, dict) else {}
company = related.get("company", {}) if isinstance(related, dict) else {}
job_number = data.get("generated_job_id", "")
purchase_order_number = data.get("purchase_order_number", "")
return {
"event_type": payload.get("type", "") if isinstance(payload, dict) else "",
"uuid": data.get("uuid", ""),
"generated_job_id": job_number,
"job_number": job_number,
"purchase_order_number": purchase_order_number,
"status": data.get("status", ""),
"edit_date": data.get("edit_date", ""),
"company_name": company.get("name", ""),
}
def format_pills(values):
if not values:
return ""
return " ".join(f"<span class='pill'>{escape(str(v))}</span>" for v in values)
def link_with_params(path, **params):
filtered = {k: v for k, v in params.items() if v not in (None, "")}
return f"{path}?{urlencode(filtered)}" if filtered else path
@app.get("/health")
def health():
return {"ok": True, "db_path": DB_PATH}
@app.get("/", response_class=HTMLResponse)
def dashboard():
with closing(get_conn()) as conn:
counts = {
"webhook_events": conn.execute("select count(*) from webhook_events").fetchone()[0],
"webhook_objects": conn.execute("select count(*) from webhook_objects").fetchone()[0],
"webhook_form_responses": conn.execute("select count(*) from webhook_form_responses").fetchone()[0],
}
latest_event = conn.execute("select received_at from webhook_events order by id desc limit 1").fetchone()
latest_object = conn.execute("select received_at from webhook_objects order by id desc limit 1").fetchone()
latest_form = conn.execute("select received_at from webhook_form_responses order by id desc limit 1").fetchone()
cards = "".join(
f"<div class='card'><div class='muted'>{escape(name)}</div><div class='big'>{count}</div></div>"
for name, count in counts.items()
)
body = f"""
<div class='cards'>{cards}</div>
<div class='card'>
<div class='summary-grid'>
<div><strong>DB path</strong></div><div><code>{escape(DB_PATH)}</code></div>
<div><strong>Latest event</strong></div><div>{escape(latest_event[0] if latest_event else '')}</div>
<div><strong>Latest object</strong></div><div>{escape(latest_object[0] if latest_object else '')}</div>
<div><strong>Latest form response</strong></div><div>{escape(latest_form[0] if latest_form else '')}</div>
</div>
</div>
<div class='section'>
<h2>Quick links</h2>
<ul>
<li><a href='/events'>Browse webhook events</a></li>
<li><a href='/objects'>Browse object webhooks</a></li>
<li><a href='/form-responses'>Browse form responses</a></li>
</ul>
</div>
"""
return html_page("ServiceM8 Inspector", body)
@app.get("/events", response_class=HTMLResponse)
def list_events(q: str = Query(""), page: int = Query(1, ge=1)):
offset = (page - 1) * PAGE_SIZE
with closing(get_conn()) as conn:
rows = conn.execute(
"select id, received_at, payload_json, path from webhook_events order by id desc limit ? offset ?",
(PAGE_SIZE, offset),
).fetchall()
filtered = []
q_lower = q.lower().strip()
q_digits = "".join(ch for ch in q if ch.isdigit())
for row in rows:
payload = parse_json_field(row["payload_json"], {}) or {}
summary = event_summary(payload)
job_number = str(summary.get("job_number", "") or "")
purchase_order_number = str(summary.get("purchase_order_number", "") or "")
haystack = " ".join(str(v) for v in summary.values()).lower() + " " + str(payload).lower()
job_match = bool(q_digits) and (q_digits in job_number or q_digits in purchase_order_number)
if q_lower and not job_match and q_lower not in haystack:
continue
filtered.append((row, payload, summary))
table_rows = []
for row, payload, summary in filtered:
table_rows.append(
f"<tr>"
f"<td><a href='/events/{row['id']}'>{row['id']}</a></td>"
f"<td>{escape(row['received_at'])}</td>"
f"<td>{escape(summary['event_type'])}</td>"
f"<td>{escape(summary['generated_job_id'])}</td>"
f"<td>{escape(summary['uuid'])}</td>"
f"<td>{escape(summary['status'])}</td>"
f"<td>{escape(summary['company_name'])}</td>"
f"<td>{escape(row['path'])}</td>"
f"</tr>"
)
body = f"""
<div class='toolbar'>
<form method='get'>
<label>Search <input type='text' name='q' value='{escape(q)}' placeholder='job number, uuid, status, company, type'></label>
<input type='hidden' name='page' value='1'>
<button type='submit'>Filter</button>
</form>
<div class='muted'>Search supports job number / generated job ID, UUID, status, company, or event type. Showing up to {PAGE_SIZE} latest rows, then filtering in-app.</div>
</div>
<table>
<thead><tr><th>ID</th><th>Received</th><th>Type</th><th>Job ID</th><th>UUID</th><th>Status</th><th>Company</th><th>Path</th></tr></thead>
<tbody>{''.join(table_rows) or "<tr><td colspan='8'>No rows matched.</td></tr>"}</tbody>
</table>
<div class='pagination'>
{f"<a href='{link_with_params('/events', q=q, page=page-1)}'>← Prev</a>" if page > 1 else ''}
<a href='{link_with_params('/events', q=q, page=page+1)}'>Next →</a>
</div>
"""
return html_page("Webhook events", body)
@app.get("/events/{event_id}", response_class=HTMLResponse)
def event_detail(event_id: int):
with closing(get_conn()) as conn:
row = conn.execute("select * from webhook_events where id = ?", (event_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Event not found")
payload = parse_json_field(row["payload_json"], {}) or {}
headers = parse_json_field(row["headers_json"], {}) or {}
summary = event_summary(payload)
related = payload.get("related", {}) if isinstance(payload, dict) else {}
company = related.get("company", {}) if isinstance(related, dict) else {}
contacts = related.get("jobContacts", []) if isinstance(related, dict) else []
materials = related.get("jobMaterials", []) if isinstance(related, dict) else []
body = f"""
<div class='card summary-grid'>
<div><strong>ID</strong></div><div>{row['id']}</div>
<div><strong>Received</strong></div><div>{escape(row['received_at'])}</div>
<div><strong>Event type</strong></div><div>{escape(summary['event_type'])}</div>
<div><strong>UUID</strong></div><div>{escape(summary['uuid'])}</div>
<div><strong>Generated job ID</strong></div><div>{escape(summary['generated_job_id'])}</div>
<div><strong>Status</strong></div><div>{escape(summary['status'])}</div>
<div><strong>Company</strong></div><div>{escape(summary['company_name'])}</div>
<div><strong>Path</strong></div><div>{escape(row['path'])}</div>
</div>
<div class='section'>
<h2>Related summary</h2>
<div class='card summary-grid'>
<div><strong>Company name</strong></div><div>{escape(company.get('name', ''))}</div>
<div><strong>Contacts</strong></div><div>{len(contacts)}</div>
<div><strong>Materials</strong></div><div>{len(materials)}</div>
</div>
</div>
<div class='section'>
<h2>Headers</h2>
<pre>{escape(pretty_json(headers))}</pre>
</div>
<div class='section'>
<h2>Payload</h2>
<pre>{escape(pretty_json(payload))}</pre>
</div>
"""
return html_page(f"Event {event_id}", body)
@app.get("/objects", response_class=HTMLResponse)
def list_objects(page: int = Query(1, ge=1)):
offset = (page - 1) * PAGE_SIZE
with closing(get_conn()) as conn:
rows = conn.execute(
"select id, received_at, object_type, object_uuid, changed_fields_json, object_time_utc, resource_url from webhook_objects order by id desc limit ? offset ?",
(PAGE_SIZE, offset),
).fetchall()
table_rows = []
for row in rows:
changed = parse_json_field(row["changed_fields_json"], []) or []
table_rows.append(
f"<tr>"
f"<td><a href='/objects/{row['id']}'>{row['id']}</a></td>"
f"<td>{escape(row['received_at'])}</td>"
f"<td>{escape(row['object_type'] or '')}</td>"
f"<td>{escape(row['object_uuid'] or '')}</td>"
f"<td>{format_pills(changed[:8])}</td>"
f"<td>{escape(row['object_time_utc'] or '')}</td>"
f"<td><a href='{escape(row['resource_url'] or '')}'>{escape(row['resource_url'] or '')}</a></td>"
f"</tr>"
)
body = f"""
<table>
<thead><tr><th>ID</th><th>Received</th><th>Object</th><th>UUID</th><th>Changed fields</th><th>Object time</th><th>Resource URL</th></tr></thead>
<tbody>{''.join(table_rows) or "<tr><td colspan='7'>No rows found.</td></tr>"}</tbody>
</table>
<div class='pagination'>
{f"<a href='{link_with_params('/objects', page=page-1)}'>← Prev</a>" if page > 1 else ''}
<a href='{link_with_params('/objects', page=page+1)}'>Next →</a>
</div>
"""
return html_page("Object webhooks", body)
@app.get("/objects/{object_id}", response_class=HTMLResponse)
def object_detail(object_id: int):
with closing(get_conn()) as conn:
row = conn.execute("select * from webhook_objects where id = ?", (object_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Object row not found")
payload = parse_json_field(row["payload_json"], {}) or {}
headers = parse_json_field(row["headers_json"], {}) or {}
changed = parse_json_field(row["changed_fields_json"], []) or []
body = f"""
<div class='card summary-grid'>
<div><strong>ID</strong></div><div>{row['id']}</div>
<div><strong>Received</strong></div><div>{escape(row['received_at'])}</div>
<div><strong>Object type</strong></div><div>{escape(row['object_type'] or '')}</div>
<div><strong>Object UUID</strong></div><div>{escape(row['object_uuid'] or '')}</div>
<div><strong>Object time UTC</strong></div><div>{escape(row['object_time_utc'] or '')}</div>
<div><strong>Resource URL</strong></div><div><a href='{escape(row['resource_url'] or '')}'>{escape(row['resource_url'] or '')}</a></div>
<div><strong>Changed fields</strong></div><div>{format_pills(changed)}</div>
</div>
<div class='section'><h2>Headers</h2><pre>{escape(pretty_json(headers))}</pre></div>
<div class='section'><h2>Payload</h2><pre>{escape(pretty_json(payload))}</pre></div>
"""
return html_page(f"Object webhook {object_id}", body)
@app.get("/form-responses", response_class=HTMLResponse)
def list_form_responses(page: int = Query(1, ge=1)):
offset = (page - 1) * PAGE_SIZE
with closing(get_conn()) as conn:
rows = conn.execute(
"select id, received_at, payload_json from webhook_form_responses order by id desc limit ? offset ?",
(PAGE_SIZE, offset),
).fetchall()
table_rows = []
for row in rows:
payload = parse_json_field(row["payload_json"], {}) or {}
data = payload.get("data", {}) if isinstance(payload, dict) else {}
related = payload.get("related", {}) if isinstance(payload, dict) else {}
job = related.get("job", {}) if isinstance(related, dict) else {}
table_rows.append(
f"<tr>"
f"<td><a href='/form-responses/{row['id']}'>{row['id']}</a></td>"
f"<td>{escape(row['received_at'])}</td>"
f"<td>{escape(data.get('form_uuid', ''))}</td>"
f"<td>{escape(data.get('regarding_object', ''))}</td>"
f"<td>{escape(data.get('regarding_object_uuid', ''))}</td>"
f"<td>{escape(job.get('generated_job_id', ''))}</td>"
f"<td>{escape(job.get('status', ''))}</td>"
f"</tr>"
)
body = f"""
<table>
<thead><tr><th>ID</th><th>Received</th><th>Form UUID</th><th>Regarding</th><th>Object UUID</th><th>Job ID</th><th>Job status</th></tr></thead>
<tbody>{''.join(table_rows) or "<tr><td colspan='7'>No rows found.</td></tr>"}</tbody>
</table>
<div class='pagination'>
{f"<a href='{link_with_params('/form-responses', page=page-1)}'>← Prev</a>" if page > 1 else ''}
<a href='{link_with_params('/form-responses', page=page+1)}'>Next →</a>
</div>
"""
return html_page("Form responses", body)
@app.get("/form-responses/{row_id}", response_class=HTMLResponse)
def form_response_detail(row_id: int):
with closing(get_conn()) as conn:
row = conn.execute("select * from webhook_form_responses where id = ?", (row_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Form response not found")
payload = parse_json_field(row["payload_json"], {}) or {}
headers = parse_json_field(row["headers_json"], {}) or {}
data = payload.get("data", {}) if isinstance(payload, dict) else {}
related = payload.get("related", {}) if isinstance(payload, dict) else {}
job = related.get("job", {}) if isinstance(related, dict) else {}
field_data_raw = data.get("field_data", "[]")
field_data = parse_json_field(field_data_raw, []) or []
field_rows = []
for item in sorted(field_data, key=lambda x: x.get("SortOrder", 0)):
field_rows.append(
f"<tr>"
f"<td>{escape(str(item.get('SortOrder', '')))}</td>"
f"<td>{escape(item.get('FieldType', ''))}</td>"
f"<td>{escape(item.get('Question', ''))}</td>"
f"<td>{escape(item.get('Response', ''))}</td>"
f"</tr>"
)
body = f"""
<div class='card summary-grid'>
<div><strong>ID</strong></div><div>{row['id']}</div>
<div><strong>Received</strong></div><div>{escape(row['received_at'])}</div>
<div><strong>Form UUID</strong></div><div>{escape(data.get('form_uuid', ''))}</div>
<div><strong>Regarding object</strong></div><div>{escape(data.get('regarding_object', ''))}</div>
<div><strong>Regarding UUID</strong></div><div>{escape(data.get('regarding_object_uuid', ''))}</div>
<div><strong>Timestamp</strong></div><div>{escape(data.get('timestamp', ''))}</div>
<div><strong>Job ID</strong></div><div>{escape(job.get('generated_job_id', ''))}</div>
<div><strong>Job status</strong></div><div>{escape(job.get('status', ''))}</div>
</div>
<div class='section'>
<h2>Decoded field data</h2>
<table>
<thead><tr><th>Order</th><th>Type</th><th>Question</th><th>Response</th></tr></thead>
<tbody>{''.join(field_rows) or "<tr><td colspan='4'>No decoded field data.</td></tr>"}</tbody>
</table>
</div>
<div class='section'><h2>Related job</h2><pre>{escape(pretty_json(job))}</pre></div>
<div class='section'><h2>Headers</h2><pre>{escape(pretty_json(headers))}</pre></div>
<div class='section'><h2>Payload</h2><pre>{escape(pretty_json(payload))}</pre></div>
"""
return html_page(f"Form response {row_id}", body)
if __name__ == "__main__":
import uvicorn
uvicorn.run("servicem8_inspector:app", host=APP_HOST, port=APP_PORT, reload=False)
Binary file not shown.