How to send a json POST to remote (camera) API from Lua script?

ypwlng

New Member
Hello - I would like to send a POST to the camera API from Lua, directly if possible, on change of scene. I do not need to have the responses from the API.

Presently the workarounds I have tried are not suitable or not ideal.

1. os.execute to call Curl - there is a lag, and the scene change is delayed

2. io.popen a Python helper script; LUA ---io pipe---> Helper ---POST---> Camera API. With this it works, but there is the inconvenient cmd console window that needs to be closed/minimised

ChatGPT thinks that there is a "socket.http" library in OBS Lua - but there isn't.

I am using OBS 32.1.1 on Windows 11.

Thank you in advance for any help, suggestions, etc.
 
Hello — OBS Lua doesn’t support socket.http, so direct POST isn’t available. A simple workaround is to use a small compiled helper (e.g., Go/C) instead of Python to avoid the console window and reduce lag, or handle the POST externally via OBS WebSocket on scene change.
Thanks. I tried python compiled into an executable file, but the console window still appears. I looked briefly at OBS WebSocket - it seems to be for remote control of OBS. Are you saying that a helper file could be written to connect to Web Socket and poll for changes or listen for what OBS is doing?
 
The lpOptional (WINHTTP_NO_REQUEST_DATA) parameter from the WinHttpSendRequest function designed exactly for POST and PUT, in case you planning to modify the code from the example by yourself.
 
The lpOptional (WINHTTP_NO_REQUEST_DATA) parameter from the WinHttpSendRequest function designed exactly for POST and PUT, in case you planning to modify the code from the example by yourself.
Thank you -- I shall set myself the challenge to learn something new and modify that code.
 
I don’t think OBS Lua has built in HTTP support, so sadly ChatGPT was wrong on that one. I ran into the same issue before.

If you don’t need the response, the cleanest option might be a small background helper app or script that stays running and listens locally, then OBS just sends a quick message to it on scene change. That avoids the lag from os.execute and gets rid of the console window issue if you run it hidden.
 
Thanks. I am working on Suslik suggestion and will update here when there is success. So far it looks possible but I need to test it out on real environment to find out whether it will work better than my helper app.
 
OBS Lua doesn't have built-in HTTP support, but the ljsocket.lua I mentioned above uses FFI to interface to Windows and OSX sockets functions. The project I referenced uses it for VISCA over IP.

I have been using it for five years for PTZ camera control. My old Aver cameras just use GET with a query string. ljsocket.lua is low level: send or receive a chunk of data. I used WIreshark to see what Aver's camera interface sent to the cameras, and wrote my Lua to construct the requests.

Since you say you don't need to examine responses to your POSTs, I am guessing the stuff you need to send is simple enough that you ought to be above to construct the data streams. If you have a client app that you can spy on, Wireshark is your friend
 
ok - so as John and Suslik have mentioned - it is possible to use the ffi library, and I have cobbled together some lua code that succesfully sends POST to my camera API. (Still working on receiving the responses, just for the sake of learning a new thing).

John your ljsocket.lua as you said is more low level than using ffi to load winhttp, I'll need to take some time to understand your ljsocket.lua first.

The problem I have now is how to deal with the situation of an offline camera - because there is a long timeout somewhere in the pathway (even when I set WinHttpSetTimeouts values to small, like 100ms), and this slows down the scene changes. It seems that it will be possible to have an asynchronous call back WinHttpOpen(..., WINHTTP_FLAG_ASYNC) but this is now making it all quite complex for what I hoped would be a simple post.
 
ljsocket.lua isn't my code - I just grabbed it from the resource I mentioned above and used it for my purposes.

Timeouts for IP are usually long. Makes sense for hosts on the other side of the world; less so for a nearby camera. I didn't run into problems because I am communicating with a server running on the same PC, so timeout would only occur if the server wasn't running, in which case ALL our cameras would be down.

The problem is that ljsocket is blocking, so your send won't complete until either success or timeout. What you want is an asynchronous notification of completion, whether success or timeout. And I don't think Lua in OBS could do that.

You mentioned Websocket above. It can notify you of scene changes, so you could move the POST with asynchronous completion to a more capable environment.

You can see a Javascript example of Websocket notification of scene change in my code https://github.com/OldBaldGeek/OBS-old-bald-scripts/blob/main/cabrini-dock.js
It's part of a browser dock that we use to control our cameras from within OBS (mostly files named cabrini-dock.*)

I'm afraid that the repository is kind of a junk drawer, with scripts and such that we use to control our setup. I set it up to keep track of changes rather than intending to publish it. So there are unrelated and obsolete files. The README should give you some idea of what's there.
 
OK for what it's worth, below is a WinHTTP library that works with OBS for sending a POST to a camera, in case anyone else is interested. Unfortunately if the camera is offline, then OBS scene changes are 'blocked' and seem sluggish - for this reason I shall be exploring John's suggestion (thanks for the inspiration from your cabrini dock).

Lua:
--[[
    WinHTTP helper library for OBS lua
    2026-04-25

    acknowledgements/thanks
    * https://obsproject.com/forum/threads/lua-run-script-hidden-no-command-window.175518/post-647153
    * ChatGPT

    USAGE:
    obs = obslua
    local WinHTTP = require("winhttp_helper")

    local http = WinHTTP.init(
        obs,
        "192.168.32.107",
        "/system?option=setinfo&login_check_flag=1"
    )

    function on_scene_change()
        http:post_async( '{"scene":"cut"}' )    -- no callback

        http:post_async( '{"scene":"cut"}', function(res)
            print(res.status .. " " .. res.body)
        end)

    end

]]

local ffi          = require("ffi")

local kernel32     = ffi.load("kernel32")
local winhttp      = ffi.load("winhttp")

------------------------------------------------------------
-- CONFIG
------------------------------------------------------------
local USER_AGENT              = "OBS-Lua"
local DEFAULT_PORT            = 80
local TIMEOUT_MS              = 100 --1000
local OBS_TIMER_TICK_INTERVAL = 100 --10
------------------------------------------------------------
-- FFI DEFINITIONS
------------------------------------------------------------
ffi.cdef [[

typedef void* HINTERNET;
typedef const wchar_t* LPCWSTR;

typedef uint32_t DWORD;
typedef uint32_t INTERNET_PORT;
typedef uintptr_t DWORD_PTR;

typedef void* LPVOID;
typedef DWORD* LPDWORD;

typedef int BOOL;

HINTERNET WinHttpOpen(LPCWSTR, DWORD, LPCWSTR, LPCWSTR, DWORD);
HINTERNET WinHttpConnect(HINTERNET, LPCWSTR, INTERNET_PORT, DWORD);
HINTERNET WinHttpOpenRequest(HINTERNET, LPCWSTR, LPCWSTR, LPCWSTR, LPCWSTR, LPCWSTR*, DWORD);

BOOL WinHttpSendRequest(HINTERNET, LPCWSTR, DWORD, LPVOID, DWORD, DWORD, DWORD_PTR);
BOOL WinHttpReceiveResponse(HINTERNET, LPVOID);
BOOL WinHttpQueryDataAvailable(HINTERNET, LPDWORD);
BOOL WinHttpReadData(HINTERNET, LPVOID, DWORD, LPDWORD);
BOOL WinHttpQueryHeaders(HINTERNET, DWORD, LPCWSTR, LPVOID, LPDWORD, LPDWORD);
BOOL WinHttpCloseHandle(HINTERNET);

BOOL WinHttpSetTimeouts(HINTERNET, int, int, int, int);

int MultiByteToWideChar(uint32_t, DWORD, const char*, int, wchar_t*, int);

DWORD GetLastError(void);

]]

------------------------------------------------------------
-- UTF8 -> UTF16
------------------------------------------------------------
local function utf8_to_wide(str)
    -- CP_UTF8 = 65001
    local needed = kernel32.MultiByteToWideChar(65001, 0, str, -1, nil, 0)

    if needed == 0 then
        return nil
    end

    local buf = ffi.new("wchar_t[?]", needed)

    kernel32.MultiByteToWideChar(65001, 0, str, -1, buf, needed)

    return buf --should return a null-terminated wide string
end

------------------------------------------------------------
-- MODULE
------------------------------------------------------------
local WinHTTP = {}
WinHTTP.__index = WinHTTP

------------------------------------------------------------
-- INIT (OBS injects timer automatically)
------------------------------------------------------------
function WinHTTP.init(obs, default_host, default_path, default_port)

    -- if script was reloaded, clean old instance first
    if WinHTTP.instance then
        WinHTTP.instance:shutdown()
    end

    local self        = setmetatable({}, WinHTTP)

    self.obs          = obs

    self.default_host = default_host
    self.default_port = default_port or DEFAULT_PORT
    self.default_path = default_path or "/"

    self.queue        = {}
    self.active       = {}

    self._timer_cb    = function() self:pump() end

    obs.timer_add(self._timer_cb, OBS_TIMER_TICK_INTERVAL)

    WinHTTP.instance = self
    return self
end

------------------------------------------------------------
-- set_config
------------------------------------------------------------
function WinHTTP:set_config(host, path, port)
    self.default_host = host or self.default_host
    self.default_port = port or self.default_port
    self.default_path = path or self.default_path
end

------------------------------------------------------------
-- SHUTDOWN
------------------------------------------------------------
function WinHTTP:shutdown()
    -- stop timer
    if self._timer_cb then
        self.obs.timer_remove(self._timer_cb)
        self._timer_cb = nil
    end

    -- clear queues
    self.queue = {}

    -- clear singleton reference
    if WinHTTP.instance == self then
        WinHTTP.instance = nil
    end
end

------------------------------------------------------------
-- PUBLIC API (scene-safe entry)
------------------------------------------------------------
function WinHTTP:post_async(body, cb)
    table.insert(self.queue, {
        host     = self.default_host, -- host_override or self.default_host,
        port     = self.default_port, -- port_override or self.default_port,
        path     = self.default_path, -- path_override or self.default_path,
        method   = "POST",
        headers  = "Content-Type: application/json",
        body     = body,
        callback = cb or nil,
        session  = nil,
        connect  = nil,
        handle   = nil
    })
end

------------------------------------------------------------
-- ERROR HANDLER
------------------------------------------------------------
local function fail(self, req, msg)
    local err = "'" .. msg .. " (GetLastError: " .. ffi.C.GetLastError() .. ")'"

    --print(err) a print here does not appear in OBS logs unless the calling script also logs something

    if req.handle then winhttp.WinHttpCloseHandle(req.handle) end
    if req.connect then winhttp.WinHttpCloseHandle(req.connect) end
    if req.session then winhttp.WinHttpCloseHandle(req.session) end
    req.chunks = nil

    if req.callback then
        req.callback({ status = "error", body = err })
    end
end

------------------------------------------------------------
-- PUMP (runs on OBS timer)
------------------------------------------------------------
function WinHTTP:pump()
    --------------------------------------------------------
    -- process ONE queued request per tick
    --------------------------------------------------------
    if #self.queue == 0 then return end

    local req = table.remove(self.queue, 1)

    ----------------------------------------------------
    -- create session
    ----------------------------------------------------
    req.session = winhttp.WinHttpOpen( utf8_to_wide(USER_AGENT), 1, nil, nil, 0 )
    -- WINHTTP_ACCESS_TYPE_NO_PROXY=1

    if req.session == nil then -- consider == ffi.NULL instead of == nil
        print("ERROR: WinHttpOpen failed")
        return
    end

    winhttp.WinHttpSetTimeouts( req.session, TIMEOUT_MS, TIMEOUT_MS, TIMEOUT_MS, TIMEOUT_MS )

    ----------------------------------------------------
    -- connect
    ----------------------------------------------------
    req.connect = winhttp.WinHttpConnect( req.session, utf8_to_wide(req.host), req.port, 0 )
    if req.connect == nil then -- consider == ffi.NULL instead of == nil
        fail(self, req, "WinHttpConnect failed")
        return
    end

    ----------------------------------------------------
    -- open request
    ----------------------------------------------------
    local flags = 0
    if req.port == 443 then flags = 0x00800000 end -- WINHTTP_FLAG_SECURE
    req.handle = winhttp.WinHttpOpenRequest( req.connect, utf8_to_wide(req.method), utf8_to_wide(req.path), nil, nil, nil, flags )
    if req.handle == nil then -- consider == ffi.NULL instead of == nil
        fail(self, req, "WinHttpOpenRequest failed")
        return
    end

    ----------------------------------------------------
    -- send
    ----------------------------------------------------
    local headers_w = utf8_to_wide(req.headers)
    local body_len = req.body and #req.body or 0

    local body_buf = nil
    if body_len > 0 then
        body_buf = ffi.new("char[?]", body_len)
        ffi.copy(body_buf, req.body, body_len)
    end

    if winhttp.WinHttpSendRequest( req.handle, headers_w, -1, body_buf, body_len, body_len, 0 ) == 0 then
        fail(self, req, "WinHttpSendRequest failed")
        return
    end

    if req.callback then
        -- specifying callback implies we want to read the response

        ----------------------------------------------------
        -- receive
        ----------------------------------------------------
        if winhttp.WinHttpReceiveResponse( req.handle, nil ) == 0 then
            fail(self, req, "WinHttpReceiveResponse failed")
            return
        end

        ----------------------------------------------------
        -- query status codes
        ----------------------------------------------------
        local status = ffi.new("uint32_t[1]")
        local status_len = ffi.new("uint32_t[1]", 4) -- sizeof(DWORD)
        if winhttp.WinHttpQueryHeaders( req.handle, 0x20000013, nil, status, status_len, nil ) == 0 then
            -- 0x20000013 = WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER
            fail(self, req, "WinHttpQueryHeaders failed")
            return
        end
        local http_status = tonumber(status[0])

        if http_status < 200 or http_status >= 300 then
            fail(self, req, "HTTP status " .. http_status)
            return
        end

        ----------------------------------------------------
        -- read data
        ----------------------------------------------------
        local chunks = {}
        local size   = ffi.new("uint32_t[1]")
        local read   = ffi.new("uint32_t[1]")
        while true do
            if winhttp.WinHttpQueryDataAvailable( req.handle, size ) == 0 then
                fail(self, req, "WinHttpQueryDataAvailable failed")
                return
            end

            local available = size[0]

            if available == 0 then break end

            local buf = ffi.new("char[?]", available)

            if winhttp.WinHttpReadData( req.handle, buf, available, read ) == 0 then
                fail(self, req, "WinHttpReadData failed")
                return
            end

            table.insert( chunks, ffi.string(buf, read[0]) )
        end

        ----------------------------------------------------
        -- return the responses via the callback
        ----------------------------------------------------
        req.callback({
            status = http_status,
            body = table.concat(chunks)
        })
    end

    --------------------------------------------------------
    -- close handles
    --------------------------------------------------------
    winhttp.WinHttpCloseHandle(req.handle)
    winhttp.WinHttpCloseHandle(req.connect)
    winhttp.WinHttpCloseHandle(req.session)

end

------------------------------------------------------------
-- EXPORT
------------------------------------------------------------
return WinHTTP
 
You can create a thread and monitor it for it for changes via pipe

To send a POST request with no popups -- use OBS os_process_pipe_t, with timer_add you can postpone reading the buffer, so it does not block the UI.

Lua:
ffi = require "ffi"

local function try_load_library(alias, name)
  if ffi.os == "OSX" then name = name .. ".0.dylib" end
  local ok, lib = pcall(ffi.load, name)
  if ok then _G[alias] = lib end
end

try_load_library("obsffi", "obs")

ffi.cdef[[
  struct os_process_pipe;
  typedef struct os_process_pipe os_process_pipe_t;
  os_process_pipe_t *os_process_pipe_create(const char *cmd_line, const char *type);
  size_t os_process_pipe_read(os_process_pipe_t *pp, uint8_t *data, size_t len);
  int os_process_pipe_destroy(os_process_pipe_t *pp);
]]

local function read_pipe_result(request_context)
  local buffer = ffi.new("uint8_t[4096]")
  local response = ""
 
  while true do
    local bytes_count = obsffi.os_process_pipe_read(request_context.pipe, buffer, 4096)
    if bytes_count == 0 then break end
    response = response .. ffi.string(buffer, bytes_count)
  end

  obsffi.os_process_pipe_destroy(request_context.pipe)
  obslua.remove_current_callback()

  if response ~= "" then
    print("Response: " .. response)
  else
    print("Response: [Empty]")
  end
end

local function execute_post_request()
  local url = "https://postman-echo.com/post"
  local payload = '{"status": "' .. math.random() .. '"}'
 
  local command = string.format(
    'powershell -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -Command ' ..
    '"$progressPreference = \'SilentlyContinue\'; ' ..
    '$result = Invoke-RestMethod -Method Post -Uri \'%s\' -Body \'%s\' -ContentType \'application/json\'; ' ..
    'Write-Output ($result | ConvertTo-Json -Compress)"',
    url, payload:gsub('"', '\\"')
  )

  local process_pipe = obsffi.os_process_pipe_create(command, "r")
 
  if process_pipe ~= nil then
    local context = { pipe = process_pipe }
    obslua.timer_add(function() read_pipe_result(context) end, 2000)
  end
end

function script_properties()
  local properties = obslua.obs_properties_create()
  obslua.obs_properties_add_button(properties, "post_trigger", "Send Request", function()
    execute_post_request()
    return true
  end)
  return properties
end
 
Thanks. My code above uses timer_add to postpone the process, and that seems to block the scene from moving to the next scene until it is completed (waiting for timeout if camera is offline even if I do not need response from camera). I'll try os_process_pipe_tto see if I can achieve what I want.
 
Back
Top