MultiPOST Sender

I needed a way to send the same JSON POSTs to multiple little displays that I've built (see here for an example: DIY ESP32 HTTP POST Display), and I couldn't locate a suitable app so I worked with Claude and ChatGPT to create the one described below.

This is BETA-ware, so BEware! Enjoy.

John Land


2025-11-16: App Ver. 1.1 removes old unused code, adds creating a default POST body if the associated parameter value is missing or blank. Also attached the driver code!


MultiPOST Sender — Installation & Usage Guide

MultiPOST Sender is a Hubitat app that sends raw POST bodies (plain text or JSON) to multiple URLs with retries, delays, and logging.
It also auto-creates a child device so Rule Machine can trigger POSTs.


1. Installation

A. Install the Parent App

  1. Go to Apps Code → New App
  2. Paste the MultiPOST Sender (Parent App) code
  3. Click Save

B. Install the Child Driver

  1. Go to Drivers Code → New Driver
  2. Paste the MultiPOST Sender Child Device driver
  3. Click Save

(When you install an app instance, it will automatically create its own child device.)


2. Create an App Instance

  1. Go to Apps → Add User App
  2. Select "MultiPOST Sender"
  3. Configure the following options:

• Optional suffix – Adds a label extension for identifying multiple instances
• Target URLs – One URL per line; each will receive a POST
• Retry attempts – How many times to retry if the POST fails
• Retry delay – Seconds to wait between retries
• Delay between URLs – Seconds to wait before sending to the next URL
• Debug logging – Recommended ON during testing

  1. Tap Done

You will now see a child device named:
MultiPOST Sender Device (Your App Name)


3. Using MultiPOST in Rule Machine

Use the child device’s custom command:
sendPosts("")

Examples

Plain text example:
sendPosts("Hello world")

Full JSON example (use single quotes so Hubitat preserves the JSON):
sendPosts('{"text":"12:10pm","bgcolor":"yellow","rotation":270}')

Clock format substitution:
@ becomes %
@@ becomes a literal @

Example:
sendPosts("Time is @H:@m")


4. How the App Sends Data

For each URL, the app will:
• Send the POST body exactly as provided
• Retry if the request fails
• Wait the configured delay before sending to the next URL
• Log all successes and failures
• Save a “last summary” viewable in the app UI


5. Viewing Results

Open the app instance to view:
• The last payload excerpt
• Per-URL status
• HTTP codes or errors
• Timestamp of the last run

This is helpful when debugging webhooks or ESP32 endpoints.


6. Multiple Instances

If you want different URL groups (e.g., LED panel, webhook relays, test server), create multiple MultiPOST Sender instances.
Use the “suffix” field to label each one clearly.

Each instance gets:
• Independent URL list
• Independent retry settings
• Its own child device
• Its own RM actions


7. Tips & Best Practices

• ESP32 users: ensure your server uses "requestContentType" so JSON arrives raw
• JSON strings in Rule Machine should look like this: '{"text":"Hello"}'
• Keep debug logging ON until you're sure everything works
• Enter URLs one per line (no commas)

APP code:

 /*======================VERSION 1.1======================
 * MultiPOST Sender (Parent App)
 * - Accepts raw string or JSON from Rule Machine
 * - Sends POST to multiple URLs with retries and delays
 * - Centralized error logging and last summary for UI/child
 * - A blank parameter value is sent with a POST body of: {"text":" "} 
     for devices that require some body
 */

definition(
    name: "MultiPOST Sender",
    namespace: "John Land",
    author: "John Land, ChatGPT, Claude",
    description: "Send raw POST bodies to multiple endpoints (textarea URLs).",
    category: "Convenience",
    iconUrl: "",
    iconX2Url: "",
    singleInstance: false
)

preferences {
    page(name: "mainPage")
}

def mainPage() {
    dynamicPage(name: "mainPage", title: "MultiPOST Sender", install: true, uninstall: true) {

        section("") {
            input "labelSuffix", "text", title: "Optional suffix for this app instance",
                  required: false, defaultValue: "", submitOnChange: true,
                  description: "This text will be appended to the app instance name"
        }

        section("Target URLs (one per line)") {
            input "targetUrls", "textarea", title: "URLs", required: true
        }

        section("Retry Settings") {
            input "retryCount", "number", title: "Retry attempts (per URL)", required: true, defaultValue: 2, range: "0..10"
            input "retryDelay", "number", title: "Delay between retries (seconds)", required: true, defaultValue: 2, range: "0..60"
        }

        section("Inter-URL Delay") {
            input "urlDelay", "number", title: "Delay between URLs (seconds)", required: true, defaultValue: 1, range: "0..60"
        }

        section("Logging") {
            input "enableDebug", "bool", title: "Enable debug logging", defaultValue: true
        }
    }
}

// ------------------------- SEND POSTS -------------------------
def sendPosts(String body) {
    // Handle null or empty body
    if (!body?.trim()) {
        log.warn "${app.label} → Empty POST body received, using default."
        body = '{"text":" "}'
    }

    // Remove outermost quotes once (Hubitat RM wraps strings)
    def t = body.trim()
    if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
        t = t.substring(1, t.length() - 1).trim()
    }

    // Convert @ → % for clock formatting, handle @@ as literal @
    t = fixPercents(t)

    // Determine if it's JSON or plain text
    boolean isJson = t.startsWith('{') && t.endsWith('}')
    def postBody = t
    
    if (!isJson) {
        // Wrap plain text in {"text":"..."} for display endpoints
        postBody = groovy.json.JsonOutput.toJson([text: t])
    }

    // Prepare URLs
    def urls = targetUrls?.split("\\r?\\n")?.findAll { it?.trim() } ?: []
    if (!urls) return [:]

    Map results = [:]

    urls.each { u ->
        def finalUrl = u.trim()
        if (!finalUrl) return

        def attempt = 1
        def maxAttempts = (retryCount ?: 0) + 1
        def finalStatus = null

        while (attempt <= maxAttempts && !finalStatus) {
            if (enableDebug) log.debug "${app.label} → POST attempt ${attempt}/${maxAttempts} to ${finalUrl} with body: ${postBody}"

            try {
                // Send raw JSON string as body
                httpPost([
                    uri: finalUrl,
                    requestContentType: "application/json",
                    body: postBody
                ]) { resp ->
                    finalStatus = [code: resp.status, error: null]
                    if (enableDebug) log.debug "${app.label} → POST to ${finalUrl} succeeded: status=${resp.status}"
                }
            } catch (groovyx.net.http.HttpResponseException e) {
                finalStatus = [code: e.statusCode, error: e?.response?.data?.toString() ?: e.message]
                log.warn "${app.label} → Attempt ${attempt} → POST to ${finalUrl} failed: status code: ${e.statusCode}, reason: ${finalStatus.error}"
            } catch (Exception e) {
                finalStatus = [code: "ERR", error: e.message]
                log.warn "${app.label} → Attempt ${attempt} → POST to ${finalUrl} failed: ${e.message}"
            }

            results[finalUrl] = finalStatus

            if (!finalStatus.code?.toString()?.matches(/2\d{2}/) && attempt < maxAttempts) {
                pauseExecution((retryDelay ?: 2) * 1000)
            }

            attempt++
        }

        pauseExecution((urlDelay ?: 1) * 1000)
    }

    // Store last summary for child device / UI
    state.lastSummary = [
        timestamp: now(),
        payloadExcerpt: t?.size() > 200 ? t[0..199] + "..." : t,
        results: results
    ]

    return results
}

// ------------------------- PERCENT SIGN FIX -------------------------
String fixPercents(String s) {
    if (!s) return s

    StringBuilder out = new StringBuilder()
    int i = 0

    while (i < s.length()) {
        char c = s.charAt(i)

        if (c == '@') {
            // @@ → @ (escaped literal)
            if (i + 1 < s.length() && s.charAt(i + 1) == '@') {
                out.append('@')
                i += 2
                continue
            } else {
                // @X → %X  (token substitution)
                out.append('%')
                i += 1
                continue
            }
        }

        // Normal character
        out.append(c)
        i += 1
    }

    return out.toString()
}

// ------------------------- INSTALL / UPDATE -------------------------
def installed() {
    log.info "${app.label} installed"
    initialize()
}

def updated() {
    log.info "${app.label} updated"

    // Remove any existing suffix from the label first
    def baseLabel = app.label.replaceAll(/ – .+$/, "")  // remove " – <suffix>" if present

    // Only update if the suffix changed
    if (labelSuffix != state.lastSuffix) {
        def newLabel = labelSuffix ? "${baseLabel} – ${labelSuffix}" : baseLabel
        app.updateLabel(newLabel)
        state.lastSuffix = labelSuffix
        log.info "App instance label updated to: ${newLabel}"
    }

    initialize()
}

def initialize() {
    // Append the custom suffix if provided
    if (labelSuffix && !state.labelSet) {
        app.updateLabel("${app.label}${labelSuffix.startsWith(' –') ? labelSuffix : ' – ' + labelSuffix}")
        state.labelSet = true
        log.info "App instance label updated to: ${app.label}"
    }

    // Child device creation / label sync
    def dni = "multipost-${app.id}"
    def child = getChildDevice(dni)
    if (!child) {
        child = addChildDevice(
            "John Land",
            "MultiPOST Sender Child Device",
            dni,
            [
                name: "MultiPOST Sender Device (${app.label})",
                label: "MultiPOST Sender Device (${app.label})",
                isComponent: true
            ]
        )
        log.info "Created child device for instance: ${app.label}"
    } else {
        // Keep child label in sync with app label
        child.setLabel("MultiPOST Sender Device (${app.label})")
    }
}

Driver code:

/**
 * MultiPOST Sender Child Device (Enhanced)
 * - Works with MultiPOST Sender parent app
 * - Forwards sendPosts(body) calls to parent
 * - Tracks last POST body, time, errors
 */

metadata {
    definition(
        name: "MultiPOST Sender Child Device",
        namespace: "John Land",
        author: "ChatGPT"
    ) {
        capability "Actuator"

        command "sendPosts", [[name: "Body*", type: "STRING"]]

        attribute "lastPostBody", "STRING"
        attribute "lastPostTime", "STRING"
        attribute "lastError", "STRING"
        attribute "parentInstance", "STRING"
    }
}

def installed() {
    log.info "Child installed: ${device.label}"
    updateParentInstanceName()
}

def updated() {
    initialize()
}

def initialize() {
    updateParentInstanceName()
}

private updateParentInstanceName() {
    if (parent) {
        sendEvent(name: "parentInstance", value: parent.app.label)
    }
}

/**
 * Called by Rule Machine or dashboard
 * Delegates sendPosts(body) to parent app
 */
def sendPosts(String body) {
    if (!parent) {
        log.warn "No parent app found — cannot send POST."
        return
    }

    // Record attempt info
    sendEvent(name: "lastPostBody", value: body)
    sendEvent(name: "lastPostTime", value: new Date().toString())

    Map results = [:]
    try {
        results = parent.sendPosts(body) ?: [:]
    } catch (Exception e) {
        sendEvent(name: "lastError", value: e.message)
        log.warn "Parent sendPosts() exception: ${e.message}"
        return
    }

    // Prepare summary per URL
    String firstCode = ""
    String firstError = ""
    StringBuilder statusText = new StringBuilder()

    results.each { url, info ->
        def codeVal = info.status?.toString() ?: "ERR"
        def errVal = info.error ?: ""

        if (!firstCode) firstCode = codeVal
        if (!firstError && errVal) firstError = errVal

        if (errVal)
            statusText.append("${url}: ${codeVal} (${errVal})\n")
        else
            statusText.append("${url}: ${codeVal}\n")
    }

    sendEvent(name: "lastError", value: firstError ?: "")
}

/**
 * Called by parent to synchronize child label if needed
 */
def syncLabel(newLabel) {
    device.setLabel(newLabel)
}